| author | |
| committer | |
| log | 32c6012dabdbd0eb178b25ea4225d889ec8f6475 |
| tree | e8c0668d62c8431664afee2a6e2f7251f1c6849f |
| parent | 6f34ad3e87eba2ffba66cac5593dae8b680e5b84 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 = { |
| 20 | 20 | diffIgnoreWhitespace: true, |
| 21 | 21 | favorites: [], |
| 22 | 22 | providerModelPreferences: {}, |
| 23 | sidebarAutoSettleAfterDays: 3, | |
| 23 | 24 | sidebarProjectGroupingMode: "repository_path", |
| 24 | 25 | sidebarProjectGroupingOverrides: { |
| 25 | 26 | "environment-1:/tmp/project-a": "separate", |
| ... | ... | @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { |
| 27 | 28 | sidebarProjectSortOrder: "manual", |
| 28 | 29 | sidebarThreadSortOrder: "created_at", |
| 29 | 30 | sidebarThreadPreviewCount: 6, |
| 31 | sidebarV2Enabled: false, | |
| 30 | 32 | timestampFormat: "24-hour", |
| 31 | 33 | wordWrap: true, |
| 32 | 34 | }; |
apps/mobile/src/features/archive/archivedThreadList.test.ts+2| ... | ... | @@ -41,6 +41,8 @@ function makeThread( |
| 41 | 41 | hasPendingUserInput: false, |
| 42 | 42 | hasActionableProposedPlan: false, |
| 43 | 43 | ...input, |
| 44 | settledOverride: input.settledOverride ?? null, | |
| 45 | settledAt: input.settledAt ?? null, | |
| 44 | 46 | }; |
| 45 | 47 | } |
| 46 | 48 |
apps/mobile/src/features/home/HomeRouteScreen.tsx+6-1| ... | ... | @@ -28,7 +28,8 @@ export function HomeRouteScreen() { |
| 28 | 28 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 29 | 29 | const navigation = useNavigation(); |
| 30 | 30 | const [searchQuery, setSearchQuery] = useState(""); |
| 31 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | |
| 31 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | |
| 32 | useThreadListActions(); | |
| 32 | 33 | const pendingTasks = usePendingNewTasks(); |
| 33 | 34 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); |
| 34 | 35 | const environments = useMemo( |
| ... | ... | @@ -111,6 +112,8 @@ export function HomeRouteScreen() { |
| 111 | 112 | } |
| 112 | 113 | onArchiveThread={archiveThread} |
| 113 | 114 | onDeleteThread={confirmDeleteThread} |
| 115 | onSettleThread={settleThread} | |
| 116 | onUnsettleThread={unsettleThread} | |
| 114 | 117 | onEnvironmentChange={setSelectedEnvironmentId} |
| 115 | 118 | onOpenEnvironments={() => |
| 116 | 119 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) |
| ... | ... | @@ -120,6 +123,8 @@ export function HomeRouteScreen() { |
| 120 | 123 | onProjectSortOrderChange={setProjectSortOrder} |
| 121 | 124 | onSearchQueryChange={setSearchQuery} |
| 122 | 125 | onSelectThread={(thread) => { |
| 126 | // Settled threads are live shells: opening one is plain | |
| 127 | // navigation, and sending a message un-settles server-side. | |
| 123 | 128 | navigation.navigate("Thread", { |
| 124 | 129 | environmentId: thread.environmentId, |
| 125 | 130 | threadId: thread.id, |
apps/mobile/src/features/home/HomeScreen.tsx+384-2| ... | ... | @@ -14,18 +14,22 @@ import type { |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 16 | 16 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 18 | import { ActivityIndicator, Platform, View } from "react-native"; | |
| 17 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 18 | import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 19 | 19 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 20 | 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 21 | 21 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 22 | 22 | |
| 23 | import { AppText as Text } from "../../components/AppText"; | |
| 23 | 24 | import { EmptyState } from "../../components/EmptyState"; |
| 25 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 24 | 26 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 25 | 27 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 28 | import { cn } from "../../lib/cn"; | |
| 26 | 29 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 27 | 30 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 28 | 31 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 32 | import { environmentServerConfigsAtom } from "../../state/server"; | |
| 29 | 33 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 30 | 34 | import { |
| 31 | 35 | PendingTaskListRow, |
| ... | ... | @@ -33,6 +37,8 @@ import { |
| 33 | 37 | ThreadListRow, |
| 34 | 38 | ThreadListShowMoreRow, |
| 35 | 39 | } from "../threads/thread-list-items"; |
| 40 | import { ThreadListV2Row } from "../threads/thread-list-v2-items"; | |
| 41 | import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; | |
| 36 | 42 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; |
| 37 | 43 | import { |
| 38 | 44 | buildHomeListLayout, |
| ... | ... | @@ -74,6 +80,9 @@ interface HomeScreenProps { |
| 74 | 80 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 75 | 81 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 76 | 82 | 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; | |
| 77 | 86 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 78 | 87 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 79 | 88 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; |
| ... | ... | @@ -82,6 +91,10 @@ interface HomeScreenProps { |
| 82 | 91 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 83 | 92 | |
| 84 | 93 | const 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. | |
| 96 | const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; | |
| 97 | const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; | |
| 85 | 98 | /** |
| 86 | 99 | * Top spacing between the list and the Android custom header. The Android |
| 87 | 100 | * header (AndroidHomeHeader) is rendered in-flow above this screen and |
| ... | ... | @@ -156,6 +169,80 @@ function HomeTopContentSpacer() { |
| 156 | 169 | return <View className="h-4" />; |
| 157 | 170 | } |
| 158 | 171 | |
| 172 | function 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 | ||
| 159 | 246 | /* ─── Main screen ────────────────────────────────────────────────────── */ |
| 160 | 247 | |
| 161 | 248 | export function HomeScreen(props: HomeScreenProps) { |
| ... | ... | @@ -163,6 +250,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 163 | 250 | ReadonlyMap<string, HomeGroupDisplayState> |
| 164 | 251 | >(() => new Map()); |
| 165 | 252 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 253 | const threadListV2Enabled = | |
| 254 | AsyncResult.isSuccess(preferencesResult) && | |
| 255 | preferencesResult.value.threadListV2Enabled === true; | |
| 166 | 256 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 167 | 257 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 168 | 258 | const listRef = useRef<LegendListRef | null>(null); |
| ... | ... | @@ -267,6 +357,193 @@ export function HomeScreen(props: HomeScreenProps) { |
| 267 | 357 | return map; |
| 268 | 358 | }, [props.projects]); |
| 269 | 359 | |
| 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 | ||
| 270 | 547 | const extraData = useMemo( |
| 271 | 548 | () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), |
| 272 | 549 | [props.savedConnectionsById, projectCwdByKey], |
| ... | ... | @@ -360,6 +637,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 360 | 637 | const keyExtractor = useCallback((item: HomeListItem) => item.key, []); |
| 361 | 638 | |
| 362 | 639 | /* 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. | |
| 363 | 644 | const hasAnyThreads = |
| 364 | 645 | props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; |
| 365 | 646 | const hasResults = projectGroups.length > 0; |
| ... | ... | @@ -436,6 +717,44 @@ export function HomeScreen(props: HomeScreenProps) { |
| 436 | 717 | </> |
| 437 | 718 | ); |
| 438 | 719 | |
| 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 | ||
| 439 | 758 | const listEmpty = !hasResults ? ( |
| 440 | 759 | hasSearchQuery ? ( |
| 441 | 760 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> |
| ... | ... | @@ -448,6 +767,69 @@ export function HomeScreen(props: HomeScreenProps) { |
| 448 | 767 | <EmptyState title="No threads yet" detail="Create a task to start a new coding session." /> |
| 449 | 768 | ) |
| 450 | 769 | ) : 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 | } | |
| 451 | 833 | |
| 452 | 834 | return ( |
| 453 | 835 | <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 { |
| 47 | 47 | createdAt: "2026-06-01T00:00:00.000Z", |
| 48 | 48 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 49 | 49 | archivedAt: null, |
| 50 | settledOverride: null, | |
| 51 | settledAt: null, | |
| 50 | 52 | session: null, |
| 51 | 53 | latestUserMessageAt: null, |
| 52 | 54 | hasPendingApprovals: false, |
apps/mobile/src/features/home/homeThreadList.test.ts+2| ... | ... | @@ -41,6 +41,8 @@ function makeThread( |
| 41 | 41 | hasPendingUserInput: false, |
| 42 | 42 | hasActionableProposedPlan: false, |
| 43 | 43 | ...input, |
| 44 | settledOverride: input.settledOverride ?? null, | |
| 45 | settledAt: input.settledAt ?? null, | |
| 44 | 46 | }; |
| 45 | 47 | } |
| 46 | 48 |
apps/mobile/src/features/home/thread-swipe-actions.tsx+17-3| ... | ... | @@ -167,6 +167,13 @@ export function ThreadSwipeable(props: { |
| 167 | 167 | /** Disables NEW swipe activations (e.g. while the list scrolls). */ |
| 168 | 168 | readonly enabled?: boolean; |
| 169 | 169 | 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"; | |
| 170 | 177 | readonly fullSwipeWidth: number; |
| 171 | 178 | readonly onDelete: () => void; |
| 172 | 179 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; |
| ... | ... | @@ -239,7 +246,11 @@ export function ThreadSwipeable(props: { |
| 239 | 246 | if (fullSwipeArmedRef.current) { |
| 240 | 247 | fullSwipeArmedRef.current = false; |
| 241 | 248 | methods.close(); |
| 242 | props.onDelete(); | |
| 249 | if (props.fullSwipeAction === "primary") { | |
| 250 | props.primaryAction.onPress(); | |
| 251 | } else { | |
| 252 | props.onDelete(); | |
| 253 | } | |
| 243 | 254 | } |
| 244 | 255 | }} |
| 245 | 256 | overshootFriction={1} |
| ... | ... | @@ -247,6 +258,7 @@ export function ThreadSwipeable(props: { |
| 247 | 258 | renderRightActions={(_progress, translation, methods) => ( |
| 248 | 259 | <ThreadSwipeActions |
| 249 | 260 | backgroundColor={props.backgroundColor} |
| 261 | fullSwipeAction={props.fullSwipeAction ?? "delete"} | |
| 250 | 262 | fullSwipeThreshold={fullSwipeThreshold} |
| 251 | 263 | onDelete={props.onDelete} |
| 252 | 264 | onFullSwipeArmedChange={handleFullSwipeArmedChange} |
| ... | ... | @@ -422,6 +434,7 @@ function SwipeActionButton(props: { |
| 422 | 434 | |
| 423 | 435 | export function ThreadSwipeActions(props: { |
| 424 | 436 | readonly backgroundColor: ColorValue; |
| 437 | readonly fullSwipeAction?: "delete" | "primary"; | |
| 425 | 438 | readonly fullSwipeThreshold: number; |
| 426 | 439 | readonly onDelete: () => void; |
| 427 | 440 | readonly onFullSwipeArmedChange: (armed: boolean) => void; |
| ... | ... | @@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: { |
| 430 | 443 | readonly threadTitle: string; |
| 431 | 444 | readonly translation: SharedValue<number>; |
| 432 | 445 | }) { |
| 446 | const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; | |
| 433 | 447 | useAnimatedReaction( |
| 434 | 448 | () => -props.translation.value >= props.fullSwipeThreshold, |
| 435 | 449 | (armed, previous) => { |
| ... | ... | @@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: { |
| 457 | 471 | icon={props.primaryAction.icon} |
| 458 | 472 | label={props.primaryAction.label} |
| 459 | 473 | onPress={props.primaryAction.onPress} |
| 460 | stretchesOnFullSwipe={false} | |
| 474 | stretchesOnFullSwipe={fullSwipeIsPrimary} | |
| 461 | 475 | translation={props.translation} |
| 462 | 476 | /> |
| 463 | 477 | <SwipeActionButton |
| ... | ... | @@ -471,7 +485,7 @@ export function ThreadSwipeActions(props: { |
| 471 | 485 | props.swipeableMethods.close(); |
| 472 | 486 | props.onDelete(); |
| 473 | 487 | }} |
| 474 | stretchesOnFullSwipe | |
| 488 | stretchesOnFullSwipe={!fullSwipeIsPrimary} | |
| 475 | 489 | translation={props.translation} |
| 476 | 490 | /> |
| 477 | 491 | </View> |
apps/mobile/src/features/home/useThreadListActions.ts+109-19| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | 3 | import * as Cause from "effect/Cause"; |
| 3 | 4 | import * as Haptics from "expo-haptics"; |
| 4 | 5 | import { useCallback, useRef } from "react"; |
| ... | ... | @@ -6,19 +7,37 @@ import { Alert } from "react-native"; |
| 6 | 7 | |
| 7 | 8 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; |
| 8 | 9 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 10 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; | |
| 11 | import { appAtomRegistry } from "../../state/atom-registry"; | |
| 12 | import { environmentServerConfigsAtom } from "../../state/server"; | |
| 9 | 13 | import { threadEnvironment } from "../../state/threads"; |
| 10 | 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 11 | 15 | |
| 12 | type 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). */ | |
| 18 | function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { | |
| 19 | return ( | |
| 20 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 21 | .threadSettlement === true | |
| 22 | ); | |
| 23 | } | |
| 24 | ||
| 25 | type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; | |
| 26 | ||
| 27 | const ACTION_VERBS: Record<ThreadListAction, string> = { | |
| 28 | archive: "archived", | |
| 29 | unarchive: "unarchived", | |
| 30 | delete: "deleted", | |
| 31 | settle: "settled", | |
| 32 | unsettle: "un-settled", | |
| 33 | }; | |
| 13 | 34 | |
| 14 | 35 | function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string { |
| 15 | 36 | const error = Cause.squash(cause); |
| 16 | 37 | if (error instanceof Error && error.message.trim().length > 0) { |
| 17 | 38 | return error.message; |
| 18 | 39 | } |
| 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]}.`; | |
| 22 | 41 | } |
| 23 | 42 | |
| 24 | 43 | function selectionHaptic(): void { |
| ... | ... | @@ -28,54 +47,115 @@ function selectionHaptic(): void { |
| 28 | 47 | function actionFailureTitle(action: ThreadListAction): string { |
| 29 | 48 | if (action === "archive") return "Could not archive thread"; |
| 30 | 49 | 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"; | |
| 31 | 52 | return "Could not delete thread"; |
| 32 | 53 | } |
| 33 | 54 | |
| 55 | /** Resolves to true iff the action was dispatched and succeeded. */ | |
| 34 | 56 | function useThreadActionExecutor( |
| 35 | 57 | onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, |
| 36 | 58 | ) { |
| 37 | 59 | const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); |
| 38 | 60 | const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); |
| 39 | 61 | const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); |
| 62 | const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); | |
| 63 | const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); | |
| 40 | 64 | const inFlightThreadKeys = useRef(new Set<string>()); |
| 41 | 65 | |
| 42 | 66 | const executeAction = useCallback( |
| 43 | 67 | async (action: ThreadListAction, thread: EnvironmentThreadShell) => { |
| 44 | 68 | const key = scopedThreadKey(thread.environmentId, thread.id); |
| 45 | 69 | if (inFlightThreadKeys.current.has(key)) { |
| 46 | return; | |
| 70 | return false; | |
| 47 | 71 | } |
| 48 | 72 | |
| 49 | 73 | inFlightThreadKeys.current.add(key); |
| 50 | 74 | selectionHaptic(); |
| 51 | 75 | 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 | }); | |
| 62 | 129 | if (result._tag === "Failure") { |
| 63 | 130 | 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); | |
| 65 | 137 | } |
| 66 | 138 | onCompleted?.(action, thread); |
| 139 | return true; | |
| 67 | 140 | } finally { |
| 68 | 141 | inFlightThreadKeys.current.delete(key); |
| 69 | 142 | } |
| 70 | 143 | }, |
| 71 | [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], | |
| 144 | [ | |
| 145 | archiveMutation, | |
| 146 | deleteMutation, | |
| 147 | onCompleted, | |
| 148 | settleMutation, | |
| 149 | unarchiveMutation, | |
| 150 | unsettleMutation, | |
| 151 | ], | |
| 72 | 152 | ); |
| 73 | 153 | |
| 74 | 154 | return executeAction; |
| 75 | 155 | } |
| 76 | 156 | |
| 77 | 157 | function useConfirmDeleteThread( |
| 78 | executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<void>, | |
| 158 | executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<boolean>, | |
| 79 | 159 | ) { |
| 80 | 160 | return useCallback( |
| 81 | 161 | (thread: EnvironmentThreadShell) => { |
| ... | ... | @@ -111,6 +191,8 @@ function useConfirmDeleteThread( |
| 111 | 191 | export function useThreadListActions(): { |
| 112 | 192 | readonly archiveThread: (thread: EnvironmentThreadShell) => void; |
| 113 | 193 | readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 194 | readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 195 | readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; | |
| 114 | 196 | } { |
| 115 | 197 | const executeAction = useThreadActionExecutor(); |
| 116 | 198 | |
| ... | ... | @@ -120,10 +202,18 @@ export function useThreadListActions(): { |
| 120 | 202 | }, |
| 121 | 203 | [executeAction], |
| 122 | 204 | ); |
| 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 | ); | |
| 123 | 213 | |
| 124 | 214 | const confirmDeleteThread = useConfirmDeleteThread(executeAction); |
| 125 | 215 | |
| 126 | return { archiveThread, confirmDeleteThread }; | |
| 216 | return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; | |
| 127 | 217 | } |
| 128 | 218 | |
| 129 | 219 | export function useArchivedThreadListActions( |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+33| ... | ... | @@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() { |
| 123 | 123 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> |
| 124 | 124 | </SettingsSection> |
| 125 | 125 | |
| 126 | <BetaSettingsSection /> | |
| 127 | ||
| 126 | 128 | <ArchivedThreadsSettingsSection /> |
| 127 | 129 | |
| 128 | 130 | <AppSettingsSection /> |
| ... | ... | @@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() { |
| 506 | 508 | <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" /> |
| 507 | 509 | </SettingsSection> |
| 508 | 510 | |
| 511 | <BetaSettingsSection /> | |
| 512 | ||
| 509 | 513 | <ArchivedThreadsSettingsSection /> |
| 510 | 514 | |
| 511 | 515 | <AppSettingsSection /> |
| ... | ... | @@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() { |
| 514 | 518 | ); |
| 515 | 519 | } |
| 516 | 520 | |
| 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 | */ | |
| 525 | function 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 | ||
| 517 | 550 | function AppSettingsSection() { |
| 518 | 551 | const icon = useThemeColor("--color-icon"); |
| 519 | 552 |
apps/mobile/src/features/threads/thread-list-v2-items.tsx created+329| ... | ... | @@ -0,0 +1,329 @@ |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 6 | import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; | |
| 7 | import { Platform, Pressable, useWindowDimensions, View } from "react-native"; | |
| 8 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | |
| 9 | ||
| 10 | import { AppText as Text } from "../../components/AppText"; | |
| 11 | import { ControlPillMenu } from "../../components/ControlPill"; | |
| 12 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | |
| 13 | import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 14 | import { cn } from "../../lib/cn"; | |
| 15 | import { relativeTime } from "../../lib/time"; | |
| 16 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 17 | import { useThreadPr } from "../../state/use-thread-pr"; | |
| 18 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | |
| 19 | import { 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 | ||
| 26 | const MONO_FONT = Platform.select({ | |
| 27 | ios: "Menlo", | |
| 28 | android: "monospace", | |
| 29 | default: "monospace", | |
| 30 | }); | |
| 31 | ||
| 32 | const 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 | ||
| 41 | function 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. | |
| 47 | const CARD_MENU_ACTIONS: MenuAction[] = [ | |
| 48 | { id: "settle", title: "Settle", image: "checkmark" }, | |
| 49 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 50 | ]; | |
| 51 | ||
| 52 | const 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. | |
| 58 | const LEGACY_MENU_ACTIONS: MenuAction[] = [ | |
| 59 | { id: "archive", title: "Archive", image: "archivebox" }, | |
| 60 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | |
| 61 | ]; | |
| 62 | ||
| 63 | export 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 | ||
| 73 | export 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 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { | |
| 6 | buildThreadListV2Items, | |
| 7 | resolveThreadListV2Status, | |
| 8 | sortThreadsForListV2, | |
| 9 | } from "./threadListV2"; | |
| 10 | ||
| 11 | const environmentId = EnvironmentId.make("environment-1"); | |
| 12 | ||
| 13 | function 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 | ||
| 39 | const NOW = "2026-06-02T00:00:00.000Z"; | |
| 40 | ||
| 41 | describe("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 | ||
| 68 | describe("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 | ||
| 79 | describe("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 | ||
| 178 | describe("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 @@ |
| 1 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 3 | import 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 | */ | |
| 13 | export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; | |
| 14 | ||
| 15 | export 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. */ | |
| 35 | function 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. */ | |
| 42 | function 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 | */ | |
| 57 | export 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 | ||
| 69 | export 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 | ||
| 77 | export 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 | */ | |
| 89 | export 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( |
| 38 | 38 | hasPendingUserInput: false, |
| 39 | 39 | hasActionableProposedPlan: false, |
| 40 | 40 | ...input, |
| 41 | settledOverride: input.settledOverride ?? null, | |
| 42 | settledAt: input.settledAt ?? null, | |
| 41 | 43 | }; |
| 42 | 44 | } |
| 43 | 45 |
apps/mobile/src/lib/threadActivity.test.ts+2| ... | ... | @@ -50,6 +50,8 @@ function makeThread( |
| 50 | 50 | checkpoints: [], |
| 51 | 51 | session: null, |
| 52 | 52 | ...input, |
| 53 | settledOverride: input.settledOverride ?? null, | |
| 54 | settledAt: input.settledAt ?? null, | |
| 53 | 55 | }; |
| 54 | 56 | } |
| 55 | 57 |
apps/mobile/src/persistence/mobile-preferences.ts+10| ... | ... | @@ -22,6 +22,12 @@ export interface Preferences { |
| 22 | 22 | readonly codeWordBreak?: boolean; |
| 23 | 23 | readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>; |
| 24 | 24 | 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; | |
| 25 | 31 | } |
| 26 | 32 | |
| 27 | 33 | export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()( |
| ... | ... | @@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 71 | 77 | codeWordBreak?: boolean; |
| 72 | 78 | connectOnboardingOptOutAccounts?: ReadonlyArray<string>; |
| 73 | 79 | collapsedProjectGroups?: readonly string[]; |
| 80 | threadListV2Enabled?: boolean; | |
| 74 | 81 | } = {}; |
| 75 | 82 | |
| 76 | 83 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| ... | ... | @@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 97 | 104 | (key): key is string => typeof key === "string", |
| 98 | 105 | ); |
| 99 | 106 | } |
| 107 | if (typeof parsed.threadListV2Enabled === "boolean") { | |
| 108 | preferences.threadListV2Enabled = parsed.threadListV2Enabled; | |
| 109 | } | |
| 100 | 110 | return preferences; |
| 101 | 111 | } |
| 102 | 112 |
apps/mobile/src/state/use-thread-selection.ts+2| ... | ... | @@ -58,6 +58,8 @@ function threadDetailToShell( |
| 58 | 58 | createdAt: thread.createdAt, |
| 59 | 59 | updatedAt: thread.updatedAt, |
| 60 | 60 | archivedAt: thread.archivedAt, |
| 61 | settledOverride: thread.settledOverride, | |
| 62 | settledAt: thread.settledAt, | |
| 61 | 63 | session: thread.session, |
| 62 | 64 | latestUserMessageAt: latestUserMessageAt(thread), |
| 63 | 65 | hasPendingApprovals: false, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { |
| 136 | 136 | capabilities: { |
| 137 | 137 | repositoryIdentity: true, |
| 138 | 138 | connectionProbe: true, |
| 139 | threadSettlement: true, | |
| 139 | 140 | }, |
| 140 | 141 | }; |
| 141 | 142 |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+2| ... | ... | @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { |
| 146 | 146 | createdAt: "2026-03-03T00:00:02.000Z", |
| 147 | 147 | updatedAt: "2026-03-03T00:00:03.000Z", |
| 148 | 148 | archivedAt: null, |
| 149 | settledOverride: null, | |
| 150 | settledAt: null, | |
| 149 | 151 | deletedAt: null, |
| 150 | 152 | messages: [], |
| 151 | 153 | proposedPlans: [], |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+64| ... | ... | @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 171 | 171 | for (const row of stateRows) { |
| 172 | 172 | assert.equal(row.lastAppliedSequence, 3); |
| 173 | 173 | } |
| 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 }]); | |
| 174 | 238 | }), |
| 175 | 239 | ); |
| 176 | 240 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+34| ... | ... | @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 607 | 607 | createdAt: event.payload.createdAt, |
| 608 | 608 | updatedAt: event.payload.updatedAt, |
| 609 | 609 | archivedAt: null, |
| 610 | settledOverride: null, | |
| 611 | settledAt: null, | |
| 610 | 612 | latestUserMessageAt: null, |
| 611 | 613 | pendingApprovalCount: 0, |
| 612 | 614 | pendingUserInputCount: 0, |
| ... | ... | @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 645 | 647 | return; |
| 646 | 648 | } |
| 647 | 649 | |
| 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 | ||
| 648 | 682 | case "thread.meta-updated": { |
| 649 | 683 | const existingRow = yield* projectionThreadRepository.getById({ |
| 650 | 684 | threadId: event.payload.threadId, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+113| ... | ... | @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 308 | 308 | createdAt: "2026-02-24T00:00:02.000Z", |
| 309 | 309 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 310 | 310 | archivedAt: null, |
| 311 | settledOverride: null, | |
| 312 | settledAt: null, | |
| 311 | 313 | deletedAt: null, |
| 312 | 314 | messages: [ |
| 313 | 315 | { |
| ... | ... | @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 418 | 420 | createdAt: "2026-02-24T00:00:02.000Z", |
| 419 | 421 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 420 | 422 | archivedAt: null, |
| 423 | settledOverride: null, | |
| 424 | settledAt: null, | |
| 421 | 425 | session: { |
| 422 | 426 | threadId: ThreadId.make("thread-1"), |
| 423 | 427 | status: "running", |
| ... | ... | @@ -562,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 562 | 566 | }), |
| 563 | 567 | ); |
| 564 | 568 | |
| 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 | ||
| 565 | 678 | it.effect( |
| 566 | 679 | "reads targeted project, thread, and count queries without hydrating the full snapshot", |
| 567 | 680 | () => |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+20| ... | ... | @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 334 | 334 | created_at AS "createdAt", |
| 335 | 335 | updated_at AS "updatedAt", |
| 336 | 336 | archived_at AS "archivedAt", |
| 337 | settled_override AS "settledOverride", | |
| 338 | settled_at AS "settledAt", | |
| 337 | 339 | latest_user_message_at AS "latestUserMessageAt", |
| 338 | 340 | pending_approval_count AS "pendingApprovalCount", |
| 339 | 341 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 362 | 364 | created_at AS "createdAt", |
| 363 | 365 | updated_at AS "updatedAt", |
| 364 | 366 | archived_at AS "archivedAt", |
| 367 | settled_override AS "settledOverride", | |
| 368 | settled_at AS "settledAt", | |
| 365 | 369 | latest_user_message_at AS "latestUserMessageAt", |
| 366 | 370 | pending_approval_count AS "pendingApprovalCount", |
| 367 | 371 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 392 | 396 | created_at AS "createdAt", |
| 393 | 397 | updated_at AS "updatedAt", |
| 394 | 398 | archived_at AS "archivedAt", |
| 399 | settled_override AS "settledOverride", | |
| 400 | settled_at AS "settledAt", | |
| 395 | 401 | latest_user_message_at AS "latestUserMessageAt", |
| 396 | 402 | pending_approval_count AS "pendingApprovalCount", |
| 397 | 403 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 754 | 760 | created_at AS "createdAt", |
| 755 | 761 | updated_at AS "updatedAt", |
| 756 | 762 | archived_at AS "archivedAt", |
| 763 | settled_override AS "settledOverride", | |
| 764 | settled_at AS "settledAt", | |
| 757 | 765 | latest_user_message_at AS "latestUserMessageAt", |
| 758 | 766 | pending_approval_count AS "pendingApprovalCount", |
| 759 | 767 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1186 | 1194 | createdAt: row.createdAt, |
| 1187 | 1195 | updatedAt: row.updatedAt, |
| 1188 | 1196 | archivedAt: row.archivedAt, |
| 1197 | settledOverride: row.settledOverride, | |
| 1198 | settledAt: row.settledAt, | |
| 1189 | 1199 | deletedAt: row.deletedAt, |
| 1190 | 1200 | messages: messagesByThread.get(row.threadId) ?? [], |
| 1191 | 1201 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1384 | 1394 | createdAt: row.createdAt, |
| 1385 | 1395 | updatedAt: row.updatedAt, |
| 1386 | 1396 | archivedAt: row.archivedAt, |
| 1397 | settledOverride: row.settledOverride, | |
| 1398 | settledAt: row.settledAt, | |
| 1387 | 1399 | deletedAt: row.deletedAt, |
| 1388 | 1400 | messages: [], |
| 1389 | 1401 | proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], |
| ... | ... | @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1513 | 1525 | createdAt: row.createdAt, |
| 1514 | 1526 | updatedAt: row.updatedAt, |
| 1515 | 1527 | archivedAt: row.archivedAt, |
| 1528 | settledOverride: row.settledOverride, | |
| 1529 | settledAt: row.settledAt, | |
| 1516 | 1530 | session: sessionByThread.get(row.threadId) ?? null, |
| 1517 | 1531 | latestUserMessageAt: row.latestUserMessageAt, |
| 1518 | 1532 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | ... | @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1647 | 1661 | createdAt: row.createdAt, |
| 1648 | 1662 | updatedAt: row.updatedAt, |
| 1649 | 1663 | archivedAt: row.archivedAt, |
| 1664 | settledOverride: row.settledOverride, | |
| 1665 | settledAt: row.settledAt, | |
| 1650 | 1666 | session: sessionByThread.get(row.threadId) ?? null, |
| 1651 | 1667 | latestUserMessageAt: row.latestUserMessageAt, |
| 1652 | 1668 | hasPendingApprovals: row.pendingApprovalCount > 0, |
| ... | ... | @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1887 | 1903 | createdAt: threadRow.value.createdAt, |
| 1888 | 1904 | updatedAt: threadRow.value.updatedAt, |
| 1889 | 1905 | archivedAt: threadRow.value.archivedAt, |
| 1906 | settledOverride: threadRow.value.settledOverride, | |
| 1907 | settledAt: threadRow.value.settledAt, | |
| 1890 | 1908 | session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, |
| 1891 | 1909 | latestUserMessageAt: threadRow.value.latestUserMessageAt, |
| 1892 | 1910 | hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, |
| ... | ... | @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1981 | 1999 | createdAt: threadRow.value.createdAt, |
| 1982 | 2000 | updatedAt: threadRow.value.updatedAt, |
| 1983 | 2001 | archivedAt: threadRow.value.archivedAt, |
| 2002 | settledOverride: threadRow.value.settledOverride, | |
| 2003 | settledAt: threadRow.value.settledAt, | |
| 1984 | 2004 | deletedAt: null, |
| 1985 | 2005 | messages: messageRows.map((row) => { |
| 1986 | 2006 | const message = { |
apps/server/src/orchestration/Schemas.ts+4| ... | ... | @@ -4,11 +4,13 @@ import { |
| 4 | 4 | ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, |
| 5 | 5 | ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, |
| 6 | 6 | ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, |
| 7 | ThreadSettledPayload as ContractsThreadSettledPayloadSchema, | |
| 7 | 8 | ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, |
| 8 | 9 | ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, |
| 9 | 10 | ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, |
| 10 | 11 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, |
| 11 | 12 | ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, |
| 13 | ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, | |
| 12 | 14 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, |
| 13 | 15 | ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, |
| 14 | 16 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, |
| ... | ... | @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; |
| 29 | 31 | |
| 30 | 32 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; |
| 31 | 33 | export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; |
| 34 | export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; | |
| 32 | 35 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; |
| 33 | 36 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; |
| 34 | 37 | export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; |
| 35 | 38 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 36 | 39 | export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; |
| 40 | export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; | |
| 37 | 41 | |
| 38 | 42 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; |
| 39 | 43 | export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; |
apps/server/src/orchestration/commandInvariants.test.ts+4| ... | ... | @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { |
| 68 | 68 | createdAt: now, |
| 69 | 69 | updatedAt: now, |
| 70 | 70 | archivedAt: null, |
| 71 | settledOverride: null, | |
| 72 | settledAt: null, | |
| 71 | 73 | latestTurn: null, |
| 72 | 74 | messages: [], |
| 73 | 75 | session: null, |
| ... | ... | @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { |
| 91 | 93 | createdAt: now, |
| 92 | 94 | updatedAt: now, |
| 93 | 95 | archivedAt: null, |
| 96 | settledOverride: null, | |
| 97 | settledAt: null, | |
| 94 | 98 | latestTurn: null, |
| 95 | 99 | messages: [], |
| 96 | 100 | session: null, |
apps/server/src/orchestration/decider.settled.test.ts created+521| ... | ... | @@ -0,0 +1,521 @@ |
| 1 | import { | |
| 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"; | |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 13 | import { expect, it } from "@effect/vitest"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | ||
| 16 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 17 | ||
| 18 | const NOW = "2026-01-01T00:00:00.000Z"; | |
| 19 | const SETTLED_AT = "2025-12-30T00:00:00.000Z"; | |
| 20 | ||
| 21 | function 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 | ||
| 59 | function 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 | ||
| 71 | it.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"; |
| 24 | 24 | |
| 25 | 25 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 26 | 26 | |
| 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. | |
| 30 | const 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 | */ | |
| 42 | function 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 | ||
| 56 | function 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 | ||
| 27 | 82 | function withEventBase( |
| 28 | 83 | input: Pick<OrchestrationCommand, "commandId"> & { |
| 29 | 84 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| ... | ... | @@ -327,6 +382,132 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 327 | 382 | }; |
| 328 | 383 | } |
| 329 | 384 | |
| 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 | ||
| 330 | 511 | case "thread.meta.update": { |
| 331 | 512 | const thread = yield* requireThread({ |
| 332 | 513 | readModel, |
| ... | ... | @@ -479,7 +660,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 479 | 660 | createdAt: command.createdAt, |
| 480 | 661 | }, |
| 481 | 662 | }; |
| 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]; | |
| 483 | 684 | } |
| 484 | 685 | |
| 485 | 686 | case "thread.turn.interrupt": { |
| ... | ... | @@ -600,12 +801,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 600 | 801 | } |
| 601 | 802 | |
| 602 | 803 | case "thread.session.set": { |
| 603 | yield* requireThread({ | |
| 804 | const thread = yield* requireThread({ | |
| 604 | 805 | readModel, |
| 605 | 806 | command, |
| 606 | 807 | threadId: command.threadId, |
| 607 | 808 | }); |
| 608 | return { | |
| 809 | const sessionSetEvent: Omit<OrchestrationEvent, "sequence"> = { | |
| 609 | 810 | ...(yield* withEventBase({ |
| 610 | 811 | aggregateKind: "thread", |
| 611 | 812 | aggregateId: command.threadId, |
| ... | ... | @@ -619,6 +820,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 619 | 820 | session: command.session, |
| 620 | 821 | }, |
| 621 | 822 | }; |
| 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]; | |
| 622 | 847 | } |
| 623 | 848 | |
| 624 | 849 | case "thread.message.assistant.delta": { |
| ... | ... | @@ -745,7 +970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 745 | 970 | } |
| 746 | 971 | |
| 747 | 972 | case "thread.activity.append": { |
| 748 | yield* requireThread({ | |
| 973 | const thread = yield* requireThread({ | |
| 749 | 974 | readModel, |
| 750 | 975 | command, |
| 751 | 976 | threadId: command.threadId, |
| ... | ... | @@ -758,7 +983,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 758 | 983 | ? ((command.activity.payload as { requestId: string }) |
| 759 | 984 | .requestId as OrchestrationEvent["metadata"]["requestId"]) |
| 760 | 985 | : undefined; |
| 761 | return { | |
| 986 | const activityAppendedEvent: Omit<OrchestrationEvent, "sequence"> = { | |
| 762 | 987 | ...(yield* withEventBase({ |
| 763 | 988 | aggregateKind: "thread", |
| 764 | 989 | aggregateId: command.threadId, |
| ... | ... | @@ -772,6 +997,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 772 | 997 | activity: command.activity, |
| 773 | 998 | }, |
| 774 | 999 | }; |
| 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]; | |
| 775 | 1024 | } |
| 776 | 1025 | |
| 777 | 1026 | default: { |
apps/server/src/orchestration/projector.settled.test.ts created+88| ... | ... | @@ -0,0 +1,88 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | ProjectId, | |
| 5 | ThreadId, | |
| 6 | type OrchestrationEvent, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { expect, it } from "@effect/vitest"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | ||
| 11 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 12 | ||
| 13 | function 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 | ||
| 33 | it.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", () => { |
| 89 | 89 | createdAt: now, |
| 90 | 90 | updatedAt: now, |
| 91 | 91 | archivedAt: null, |
| 92 | settledOverride: null, | |
| 93 | settledAt: null, | |
| 92 | 94 | deletedAt: null, |
| 93 | 95 | messages: [], |
| 94 | 96 | proposedPlans: [], |
apps/server/src/orchestration/projector.ts+28| ... | ... | @@ -22,7 +22,9 @@ import { |
| 22 | 22 | ThreadMetaUpdatedPayload, |
| 23 | 23 | ThreadProposedPlanUpsertedPayload, |
| 24 | 24 | ThreadRuntimeModeSetPayload, |
| 25 | ThreadSettledPayload, | |
| 25 | 26 | ThreadUnarchivedPayload, |
| 27 | ThreadUnsettledPayload, | |
| 26 | 28 | ThreadRevertedPayload, |
| 27 | 29 | ThreadSessionSetPayload, |
| 28 | 30 | ThreadTurnDiffCompletedPayload, |
| ... | ... | @@ -286,6 +288,8 @@ export function projectEvent( |
| 286 | 288 | createdAt: payload.createdAt, |
| 287 | 289 | updatedAt: payload.updatedAt, |
| 288 | 290 | archivedAt: null, |
| 291 | settledOverride: null, | |
| 292 | settledAt: null, | |
| 289 | 293 | deletedAt: null, |
| 290 | 294 | messages: [], |
| 291 | 295 | activities: [], |
| ... | ... | @@ -337,6 +341,30 @@ export function projectEvent( |
| 337 | 341 | })), |
| 338 | 342 | ); |
| 339 | 343 | |
| 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 | ||
| 340 | 368 | case "thread.meta-updated": |
| 341 | 369 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( |
| 342 | 370 | Effect.map((payload) => ({ |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+56| ... | ... | @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 91 | 91 | createdAt: "2026-03-24T00:00:00.000Z", |
| 92 | 92 | updatedAt: "2026-03-24T00:00:00.000Z", |
| 93 | 93 | archivedAt: null, |
| 94 | settledOverride: null, | |
| 95 | settledAt: null, | |
| 94 | 96 | latestUserMessageAt: null, |
| 95 | 97 | pendingApprovalCount: 0, |
| 96 | 98 | pendingUserInputCount: 0, |
| ... | ... | @@ -128,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 128 | 130 | }); |
| 129 | 131 | }), |
| 130 | 132 | ); |
| 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 | ); | |
| 131 | 187 | }); |
apps/server/src/persistence/Layers/ProjectionThreads.ts+10| ... | ... | @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 43 | 43 | created_at, |
| 44 | 44 | updated_at, |
| 45 | 45 | archived_at, |
| 46 | settled_override, | |
| 47 | settled_at, | |
| 46 | 48 | latest_user_message_at, |
| 47 | 49 | pending_approval_count, |
| 48 | 50 | pending_user_input_count, |
| ... | ... | @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 62 | 64 | ${row.createdAt}, |
| 63 | 65 | ${row.updatedAt}, |
| 64 | 66 | ${row.archivedAt}, |
| 67 | ${row.settledOverride}, | |
| 68 | ${row.settledAt}, | |
| 65 | 69 | ${row.latestUserMessageAt}, |
| 66 | 70 | ${row.pendingApprovalCount}, |
| 67 | 71 | ${row.pendingUserInputCount}, |
| ... | ... | @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 81 | 85 | created_at = excluded.created_at, |
| 82 | 86 | updated_at = excluded.updated_at, |
| 83 | 87 | archived_at = excluded.archived_at, |
| 88 | settled_override = excluded.settled_override, | |
| 89 | settled_at = excluded.settled_at, | |
| 84 | 90 | latest_user_message_at = excluded.latest_user_message_at, |
| 85 | 91 | pending_approval_count = excluded.pending_approval_count, |
| 86 | 92 | pending_user_input_count = excluded.pending_user_input_count, |
| ... | ... | @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 107 | 113 | created_at AS "createdAt", |
| 108 | 114 | updated_at AS "updatedAt", |
| 109 | 115 | archived_at AS "archivedAt", |
| 116 | settled_override AS "settledOverride", | |
| 117 | settled_at AS "settledAt", | |
| 110 | 118 | latest_user_message_at AS "latestUserMessageAt", |
| 111 | 119 | pending_approval_count AS "pendingApprovalCount", |
| 112 | 120 | pending_user_input_count AS "pendingUserInputCount", |
| ... | ... | @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 135 | 143 | created_at AS "createdAt", |
| 136 | 144 | updated_at AS "updatedAt", |
| 137 | 145 | archived_at AS "archivedAt", |
| 146 | settled_override AS "settledOverride", | |
| 147 | settled_at AS "settledAt", | |
| 138 | 148 | latest_user_message_at AS "latestUserMessageAt", |
| 139 | 149 | pending_approval_count AS "pendingApprovalCount", |
| 140 | 150 | pending_user_input_count AS "pendingUserInputCount", |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe |
| 45 | 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; |
| 46 | 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; |
| 47 | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | |
| 48 | 49 | |
| 49 | 50 | /** |
| 50 | 51 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -89,6 +90,7 @@ export const migrationEntries = [ |
| 89 | 90 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], |
| 90 | 91 | [31, "AuthAuthorizationScopes", Migration0031], |
| 91 | 92 | [32, "AuthPairingProofKeyThumbprint", Migration0032], |
| 93 | [33, "ProjectionThreadsSettled", Migration0033], | |
| 92 | 94 | ] as const; |
| 93 | 95 | |
| 94 | 96 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export 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({ |
| 36 | 36 | createdAt: IsoDateTime, |
| 37 | 37 | updatedAt: IsoDateTime, |
| 38 | 38 | archivedAt: Schema.NullOr(IsoDateTime), |
| 39 | settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), | |
| 40 | settledAt: Schema.NullOr(IsoDateTime), | |
| 39 | 41 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 40 | 42 | pendingApprovalCount: NonNegativeInt, |
| 41 | 43 | pendingUserInputCount: NonNegativeInt, |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+2| ... | ... | @@ -100,6 +100,8 @@ function makeReadModel( |
| 100 | 100 | createdAt: now, |
| 101 | 101 | updatedAt: now, |
| 102 | 102 | archivedAt: null, |
| 103 | settledOverride: null, | |
| 104 | settledAt: null, | |
| 103 | 105 | latestUserMessageAt: null, |
| 104 | 106 | hasPendingApprovals: false, |
| 105 | 107 | hasPendingUserInput: false, |
apps/server/src/relay/AgentAwarenessRelay.test.ts+6| ... | ... | @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 305 | 305 | createdAt: now, |
| 306 | 306 | updatedAt: now, |
| 307 | 307 | archivedAt: null, |
| 308 | settledOverride: null, | |
| 309 | settledAt: null, | |
| 308 | 310 | session: null, |
| 309 | 311 | latestUserMessageAt: null, |
| 310 | 312 | hasPendingApprovals: false, |
| ... | ... | @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 451 | 453 | createdAt: now, |
| 452 | 454 | updatedAt: now, |
| 453 | 455 | archivedAt: null, |
| 456 | settledOverride: null, | |
| 457 | settledAt: null, | |
| 454 | 458 | session: { |
| 455 | 459 | threadId, |
| 456 | 460 | status: "running", |
| ... | ... | @@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { |
| 607 | 611 | createdAt: now, |
| 608 | 612 | updatedAt: now, |
| 609 | 613 | archivedAt: null, |
| 614 | settledOverride: null, | |
| 615 | settledAt: null, | |
| 610 | 616 | session: { |
| 611 | 617 | threadId, |
| 612 | 618 | status: "running", |
apps/server/src/server.test.ts+6| ... | ... | @@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => { |
| 164 | 164 | createdAt: now, |
| 165 | 165 | updatedAt: now, |
| 166 | 166 | archivedAt: null, |
| 167 | settledOverride: null, | |
| 168 | settledAt: null, | |
| 167 | 169 | latestTurn: null, |
| 168 | 170 | messages: [], |
| 169 | 171 | session: null, |
| ... | ... | @@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = ( |
| 193 | 195 | createdAt: now, |
| 194 | 196 | updatedAt: now, |
| 195 | 197 | archivedAt: null, |
| 198 | settledOverride: null, | |
| 199 | settledAt: null, | |
| 196 | 200 | session: null, |
| 197 | 201 | latestUserMessageAt: null, |
| 198 | 202 | hasPendingApprovals: false, |
| ... | ... | @@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5499 | 5503 | createdAt: now, |
| 5500 | 5504 | updatedAt: now, |
| 5501 | 5505 | archivedAt: null, |
| 5506 | settledOverride: null, | |
| 5507 | settledAt: null, | |
| 5502 | 5508 | latestTurn: null, |
| 5503 | 5509 | messages: [], |
| 5504 | 5510 | session: null, |
apps/web/src/components/AppSidebarLayout.tsx+12-2| ... | ... | @@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; |
| 8 | 8 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 9 | 9 | import { cn, isMacPlatform } from "../lib/utils"; |
| 10 | 10 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 11 | import { useClientSettings } from "../hooks/useSettings"; | |
| 11 | 12 | import ThreadSidebar from "./Sidebar"; |
| 13 | import ThreadSidebarV2 from "./SidebarV2"; | |
| 12 | 14 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; |
| 13 | 15 | import { |
| 14 | 16 | resolveInitialThreadSidebarWidth, |
| ... | ... | @@ -98,7 +100,12 @@ function SidebarControl() { |
| 98 | 100 | |
| 99 | 101 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 100 | 102 | 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. | |
| 101 | 106 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 107 | const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); | |
| 108 | const useSidebarV2 = sidebarV2Enabled && !isOnSettings; | |
| 102 | 109 | const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); |
| 103 | 110 | const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); |
| 104 | 111 | const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); |
| ... | ... | @@ -157,7 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 157 | 164 | <Sidebar |
| 158 | 165 | side="left" |
| 159 | 166 | 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 | )} | |
| 161 | 171 | resizable={{ |
| 162 | 172 | maxWidth: sidebarMaximumWidth, |
| 163 | 173 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, |
| ... | ... | @@ -168,7 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 168 | 178 | onResize: setSidebarWidth, |
| 169 | 179 | }} |
| 170 | 180 | > |
| 171 | <ThreadSidebar /> | |
| 181 | {useSidebarV2 ? <ThreadSidebarV2 /> : <ThreadSidebar />} | |
| 172 | 182 | <SidebarRail /> |
| 173 | 183 | </Sidebar> |
| 174 | 184 | {children} |
apps/web/src/components/ChatView.logic.test.ts+2| ... | ... | @@ -49,6 +49,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 49 | 49 | createdAt: now, |
| 50 | 50 | updatedAt: now, |
| 51 | 51 | archivedAt: null, |
| 52 | settledOverride: null, | |
| 53 | settledAt: null, | |
| 52 | 54 | deletedAt: null, |
| 53 | 55 | latestTurn: null, |
| 54 | 56 | branch: null, |
apps/web/src/components/ChatView.logic.ts+2| ... | ... | @@ -45,6 +45,8 @@ export function buildLocalDraftThread( |
| 45 | 45 | createdAt: draftThread.createdAt, |
| 46 | 46 | updatedAt: draftThread.createdAt, |
| 47 | 47 | archivedAt: null, |
| 48 | settledOverride: null, | |
| 49 | settledAt: null, | |
| 48 | 50 | deletedAt: null, |
| 49 | 51 | latestTurn: null, |
| 50 | 52 | branch: draftThread.branch, |
apps/web/src/components/ChatView.tsx+1| ... | ... | @@ -5220,6 +5220,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5220 | 5220 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 5221 | 5221 | activeThreadTitle={activeThread.title} |
| 5222 | 5222 | activeProjectName={activeProject?.title} |
| 5223 | activeProjectCwd={activeProject?.workspaceRoot ?? null} | |
| 5223 | 5224 | openInCwd={gitCwd} |
| 5224 | 5225 | activeProjectScripts={activeProject?.scripts} |
| 5225 | 5226 | preferredScriptId={ |
apps/web/src/components/CommandPalette.logic.test.ts+2| ... | ... | @@ -24,6 +24,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 24 | 24 | proposedPlans: [], |
| 25 | 25 | createdAt: "2026-03-01T00:00:00.000Z", |
| 26 | 26 | archivedAt: null, |
| 27 | settledOverride: null, | |
| 28 | settledAt: null, | |
| 27 | 29 | deletedAt: null, |
| 28 | 30 | updatedAt: "2026-03-01T00:00:00.000Z", |
| 29 | 31 | latestTurn: null, |
apps/web/src/components/Sidebar.logic.test.ts+98| ... | ... | @@ -18,8 +18,10 @@ import { |
| 18 | 18 | resolveSidebarNewThreadEnvMode, |
| 19 | 19 | resolveSidebarStageBadgeLabel, |
| 20 | 20 | resolveThreadRowClassName, |
| 21 | resolveSidebarV2Status, | |
| 21 | 22 | resolveThreadStatusPill, |
| 22 | 23 | shouldClearThreadSelectionOnMouseDown, |
| 24 | sortThreadsForSidebarV2, | |
| 23 | 25 | sortProjectsForSidebar, |
| 24 | 26 | sortScopedProjectsForSidebar, |
| 25 | 27 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| ... | ... | @@ -634,6 +636,100 @@ describe("isContextMenuPointerDown", () => { |
| 634 | 636 | }); |
| 635 | 637 | }); |
| 636 | 638 | |
| 639 | describe("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 | ||
| 707 | describe("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 | ||
| 637 | 733 | describe("resolveThreadStatusPill", () => { |
| 638 | 734 | const baseThread = { |
| 639 | 735 | hasActionableProposedPlan: false, |
| ... | ... | @@ -900,6 +996,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 900 | 996 | proposedPlans: [], |
| 901 | 997 | createdAt: "2026-03-09T10:00:00.000Z", |
| 902 | 998 | archivedAt: null, |
| 999 | settledOverride: null, | |
| 1000 | settledAt: null, | |
| 903 | 1001 | deletedAt: null, |
| 904 | 1002 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 905 | 1003 | latestTurn: null, |
apps/web/src/components/Sidebar.logic.ts+65| ... | ... | @@ -417,6 +417,71 @@ export function resolveThreadRowClassName(input: { |
| 417 | 417 | return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); |
| 418 | 418 | } |
| 419 | 419 | |
| 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. | |
| 427 | export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; | |
| 428 | ||
| 429 | type SidebarV2StatusInput = Pick< | |
| 430 | SidebarThreadSummary, | |
| 431 | "hasPendingApprovals" | "hasPendingUserInput" | "session" | |
| 432 | >; | |
| 433 | ||
| 434 | export 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. */ | |
| 452 | export 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. */ | |
| 460 | export 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. | |
| 475 | export 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 | ||
| 420 | 485 | export function resolveThreadStatusPill(input: { |
| 421 | 486 | thread: ThreadStatusInput; |
| 422 | 487 | }): ThreadStatusPill | null { |
apps/web/src/components/Sidebar.tsx+4-118| ... | ... | @@ -8,7 +8,6 @@ import { |
| 8 | 8 | Globe2Icon, |
| 9 | 9 | LoaderIcon, |
| 10 | 10 | SearchIcon, |
| 11 | SettingsIcon, | |
| 12 | 11 | SquarePenIcon, |
| 13 | 12 | TerminalIcon, |
| 14 | 13 | TriangleAlertIcon, |
| ... | ... | @@ -63,7 +62,7 @@ import { |
| 63 | 62 | settlePromise, |
| 64 | 63 | squashAtomCommandFailure, |
| 65 | 64 | } from "@t3tools/client-runtime/state/runtime"; |
| 66 | import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; | |
| 65 | import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; | |
| 67 | 66 | import { |
| 68 | 67 | MAX_SIDEBAR_THREAD_PREVIEW_COUNT, |
| 69 | 68 | MIN_SIDEBAR_THREAD_PREVIEW_COUNT, |
| ... | ... | @@ -74,10 +73,9 @@ import { |
| 74 | 73 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| 75 | 74 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; |
| 76 | 75 | import { isElectron } from "../env"; |
| 77 | import { APP_STAGE_LABEL } from "../branding"; | |
| 78 | 76 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 79 | 77 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 80 | import { cn, isMacPlatform } from "../lib/utils"; | |
| 78 | import { isMacPlatform } from "../lib/utils"; | |
| 81 | 79 | import { |
| 82 | 80 | readThreadShell, |
| 83 | 81 | useProject, |
| ... | ... | @@ -126,7 +124,6 @@ import { |
| 126 | 124 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 127 | 125 | import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 128 | 126 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 129 | import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | |
| 130 | 127 | import { Kbd } from "./ui/kbd"; |
| 131 | 128 | import { |
| 132 | 129 | getArm64IntelBuildWarningDescription, |
| ... | ... | @@ -169,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u |
| 169 | 166 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 170 | 167 | import { |
| 171 | 168 | SidebarContent, |
| 172 | SidebarFooter, | |
| 173 | 169 | SidebarGroup, |
| 174 | SidebarHeader, | |
| 175 | 170 | SidebarMenu, |
| 176 | 171 | SidebarMenuButton, |
| 177 | 172 | SidebarMenuItem, |
| ... | ... | @@ -179,7 +174,6 @@ import { |
| 179 | 174 | SidebarMenuSubButton, |
| 180 | 175 | SidebarMenuSubItem, |
| 181 | 176 | SidebarSeparator, |
| 182 | SidebarTrigger, | |
| 183 | 177 | useSidebar, |
| 184 | 178 | } from "./ui/sidebar"; |
| 185 | 179 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| ... | ... | @@ -194,7 +188,6 @@ import { |
| 194 | 188 | resolveProjectStatusIndicator, |
| 195 | 189 | resolveSidebarNewThreadSeedContext, |
| 196 | 190 | resolveSidebarNewThreadEnvMode, |
| 197 | resolveSidebarStageBadgeLabel, | |
| 198 | 191 | resolveThreadRowClassName, |
| 199 | 192 | resolveThreadStatusPill, |
| 200 | 193 | orderItemsByPreferredIds, |
| ... | ... | @@ -204,12 +197,12 @@ import { |
| 204 | 197 | ThreadStatusPill, |
| 205 | 198 | } from "./Sidebar.logic"; |
| 206 | 199 | import { sortThreads } from "../lib/threadSort"; |
| 207 | import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; | |
| 200 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | |
| 208 | 201 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 209 | 202 | import { useIsMobile } from "~/hooks/useMediaQuery"; |
| 210 | 203 | import { CommandDialogTrigger } from "./ui/command"; |
| 211 | 204 | import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; |
| 212 | import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; | |
| 205 | import { primaryServerKeybindingsAtom } from "../state/server"; | |
| 213 | 206 | import { |
| 214 | 207 | derivePhysicalProjectKey, |
| 215 | 208 | deriveProjectGroupingOverrideKey, |
| ... | ... | @@ -223,7 +216,6 @@ import { |
| 223 | 216 | type SidebarProjectGroupMember, |
| 224 | 217 | type SidebarProjectSnapshot, |
| 225 | 218 | } from "../sidebarProjectGrouping"; |
| 226 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; | |
| 227 | 219 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 228 | 220 | updated_at: "Last user message", |
| 229 | 221 | created_at: "Created at", |
| ... | ... | @@ -2787,112 +2779,6 @@ function SortableProjectItem({ |
| 2787 | 2779 | ); |
| 2788 | 2780 | } |
| 2789 | 2781 | |
| 2790 | const 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 | ||
| 2817 | function 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 | ||
| 2840 | function useSidebarStageLabel() { | |
| 2841 | const primaryServerVersion = | |
| 2842 | useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; | |
| 2843 | ||
| 2844 | return resolveSidebarStageBadgeLabel({ | |
| 2845 | primaryServerVersion, | |
| 2846 | fallbackStageLabel: APP_STAGE_LABEL, | |
| 2847 | }); | |
| 2848 | } | |
| 2849 | ||
| 2850 | function 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 | ||
| 2866 | const 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 | ||
| 2896 | 2782 | interface SidebarProjectsContentProps { |
| 2897 | 2783 | showArm64IntelBuildWarning: boolean; |
| 2898 | 2784 | arm64IntelBuildWarningDescription: string | null; |
apps/web/src/components/SidebarV2.tsx created+1707| ... | ... | @@ -0,0 +1,1707 @@ |
| 1 | import { autoAnimate } from "@formkit/auto-animate"; | |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 3 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; | |
| 4 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; | |
| 5 | import { | |
| 6 | scopeProjectRef, | |
| 7 | scopeThreadRef, | |
| 8 | scopedThreadKey, | |
| 9 | } from "@t3tools/client-runtime/environment"; | |
| 10 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 11 | import { | |
| 12 | CheckIcon, | |
| 13 | CircleCheckIcon, | |
| 14 | CircleDashedIcon, | |
| 15 | CloudIcon, | |
| 16 | FolderPlusIcon, | |
| 17 | PlusIcon, | |
| 18 | SearchIcon, | |
| 19 | SquarePenIcon, | |
| 20 | Undo2Icon, | |
| 21 | } from "lucide-react"; | |
| 22 | import { | |
| 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"; | |
| 32 | import { useParams, useRouter } from "@tanstack/react-router"; | |
| 33 | ||
| 34 | import { | |
| 35 | isAtomCommandInterrupted, | |
| 36 | settlePromise, | |
| 37 | squashAtomCommandFailure, | |
| 38 | } from "@t3tools/client-runtime/state/runtime"; | |
| 39 | import { isElectron } from "../env"; | |
| 40 | import { | |
| 41 | resolveShortcutCommand, | |
| 42 | shortcutLabelForCommand, | |
| 43 | shouldShowThreadJumpHintsForModifiers, | |
| 44 | threadJumpCommandForIndex, | |
| 45 | threadJumpIndexFromCommand, | |
| 46 | threadTraversalDirectionFromCommand, | |
| 47 | } from "../keybindings"; | |
| 48 | import { useShortcutModifierState } from "../shortcutModifierState"; | |
| 49 | import { isTerminalFocused } from "../lib/terminalFocus"; | |
| 50 | import { isModelPickerOpen } from "../modelPickerVisibility"; | |
| 51 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | |
| 52 | import { isMacPlatform } from "~/lib/utils"; | |
| 53 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | |
| 54 | import { readLocalApi } from "../localApi"; | |
| 55 | import { useUiStateStore } from "../uiStateStore"; | |
| 56 | import { useThreadSelectionStore } from "../threadSelectionStore"; | |
| 57 | import { useThreadActions } from "../hooks/useThreadActions"; | |
| 58 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | |
| 59 | import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; | |
| 60 | import { onOpenNewThreadPicker } from "../newThreadPickerBus"; | |
| 61 | import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; | |
| 62 | import { | |
| 63 | resolveThreadActionProjectRef, | |
| 64 | startNewThreadFromContext, | |
| 65 | startNewThreadInProjectFromContext, | |
| 66 | } from "../lib/chatThreadActions"; | |
| 67 | import { useClientSettings } from "../hooks/useSettings"; | |
| 68 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | |
| 69 | import { useProjects, useThreadShells } from "../state/entities"; | |
| 70 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; | |
| 71 | import { vcsEnvironment } from "../state/vcs"; | |
| 72 | import { threadEnvironment } from "../state/threads"; | |
| 73 | import { useEnvironmentQuery } from "../state/query"; | |
| 74 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 75 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | |
| 76 | import { formatRelativeTimeLabel } from "../timestampFormat"; | |
| 77 | import type { SidebarThreadSummary } from "../types"; | |
| 78 | import { cn } from "~/lib/utils"; | |
| 79 | import { | |
| 80 | firstValidTimestampMs, | |
| 81 | hasUnseenCompletion, | |
| 82 | isTrailingDoubleClick, | |
| 83 | resolveAdjacentThreadId, | |
| 84 | resolveSidebarV2Status, | |
| 85 | sortThreadsForSidebarV2, | |
| 86 | } from "./Sidebar.logic"; | |
| 87 | import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; | |
| 88 | import { ProjectFavicon } from "./ProjectFavicon"; | |
| 89 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | |
| 90 | import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; | |
| 91 | import { primaryServerProvidersAtom } from "../state/server"; | |
| 92 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 93 | import { CommandDialogTrigger } from "./ui/command"; | |
| 94 | import { Kbd } from "./ui/kbd"; | |
| 95 | import { | |
| 96 | SidebarContent, | |
| 97 | SidebarGroup, | |
| 98 | SidebarMenu, | |
| 99 | SidebarMenuButton, | |
| 100 | SidebarMenuItem, | |
| 101 | SidebarSeparator, | |
| 102 | useSidebar, | |
| 103 | } from "./ui/sidebar"; | |
| 104 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | |
| 105 | import { 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. | |
| 109 | const SETTLED_TAIL_INITIAL_COUNT = 10; | |
| 110 | const SETTLED_TAIL_PAGE_COUNT = 25; | |
| 111 | ||
| 112 | function compactSidebarTimeLabel(label: string): string { | |
| 113 | if (label === "just now") return "now"; | |
| 114 | return label.endsWith(" ago") ? label.slice(0, -4) : label; | |
| 115 | } | |
| 116 | ||
| 117 | function threadTimeLabel(thread: SidebarThreadSummary): string { | |
| 118 | const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; | |
| 119 | return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); | |
| 120 | } | |
| 121 | ||
| 122 | const 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 | ||
| 619 | function 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 | ||
| 628 | export 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) |
| 2143 | 2143 | ref={composerSurfaceRef} |
| 2144 | 2144 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} |
| 2145 | 2145 | 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", | |
| 2148 | 2150 | projectSelectionRequired ? "opacity-75" : null, |
| 2149 | 2151 | composerProviderState.composerSurfaceClassName, |
| 2150 | 2152 | )} |
apps/web/src/components/chat/ChatHeader.tsx+23| ... | ... | @@ -16,6 +16,7 @@ import ProjectScriptsControl, { |
| 16 | 16 | } from "../ProjectScriptsControl"; |
| 17 | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 18 | 18 | import { usePrimaryEnvironmentId } from "../../state/environments"; |
| 19 | import { ProjectFavicon } from "../ProjectFavicon"; | |
| 19 | 20 | import { cn } from "~/lib/utils"; |
| 20 | 21 | |
| 21 | 22 | interface ChatHeaderProps { |
| ... | ... | @@ -24,6 +25,7 @@ interface ChatHeaderProps { |
| 24 | 25 | draftId?: DraftId; |
| 25 | 26 | activeThreadTitle: string; |
| 26 | 27 | activeProjectName: string | undefined; |
| 28 | activeProjectCwd: string | null; | |
| 27 | 29 | openInCwd: string | null; |
| 28 | 30 | activeProjectScripts: ReadonlyArray<ProjectScript> | undefined; |
| 29 | 31 | preferredScriptId: string | null; |
| ... | ... | @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 58 | 60 | draftId, |
| 59 | 61 | activeThreadTitle, |
| 60 | 62 | activeProjectName, |
| 63 | activeProjectCwd, | |
| 61 | 64 | openInCwd, |
| 62 | 65 | activeProjectScripts, |
| 63 | 66 | preferredScriptId, |
| ... | ... | @@ -79,6 +82,26 @@ export const ChatHeader = memo(function ChatHeader({ |
| 79 | 82 | return ( |
| 80 | 83 | <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> |
| 81 | 84 | <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} | |
| 82 | 105 | <Tooltip> |
| 83 | 106 | <TooltipTrigger |
| 84 | 107 | render={ |
apps/web/src/components/settings/BetaSettingsPanel.tsx created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 2 | ||
| 3 | import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; | |
| 4 | import { Input } from "../ui/input"; | |
| 5 | import { Switch } from "../ui/switch"; | |
| 6 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | |
| 7 | ||
| 8 | const AUTO_SETTLE_MIN_DAYS = 1; | |
| 9 | const AUTO_SETTLE_MAX_DAYS = 90; | |
| 10 | const AUTO_SETTLE_DEFAULT_DAYS = 3; | |
| 11 | ||
| 12 | function 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 | ||
| 53 | export 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 { |
| 3 | 3 | ArchiveIcon, |
| 4 | 4 | ArrowLeftIcon, |
| 5 | 5 | BotIcon, |
| 6 | FlaskConicalIcon, | |
| 6 | 7 | GitBranchIcon, |
| 7 | 8 | KeyboardIcon, |
| 8 | 9 | Link2Icon, |
| ... | ... | @@ -28,6 +29,7 @@ export type SettingsSectionPath = |
| 28 | 29 | | "/settings/providers" |
| 29 | 30 | | "/settings/source-control" |
| 30 | 31 | | "/settings/connections" |
| 32 | | "/settings/beta" | |
| 31 | 33 | | "/settings/archived"; |
| 32 | 34 | |
| 33 | 35 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| ... | ... | @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 40 | 42 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, |
| 41 | 43 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 42 | 44 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 45 | { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, | |
| 43 | 46 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 44 | 47 | ]; |
| 45 | 48 |
apps/web/src/components/sidebar/SidebarChrome.tsx created+127| ... | ... | @@ -0,0 +1,127 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { SettingsIcon } from "lucide-react"; | |
| 3 | import { memo, useCallback } from "react"; | |
| 4 | import { Link, useNavigate } from "@tanstack/react-router"; | |
| 5 | ||
| 6 | import { APP_STAGE_LABEL } from "../../branding"; | |
| 7 | import { cn } from "../../lib/utils"; | |
| 8 | import { primaryServerConfigAtom } from "../../state/server"; | |
| 9 | import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; | |
| 10 | import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; | |
| 11 | import { | |
| 12 | SidebarFooter, | |
| 13 | SidebarHeader, | |
| 14 | SidebarMenu, | |
| 15 | SidebarMenuButton, | |
| 16 | SidebarMenuItem, | |
| 17 | SidebarTrigger, | |
| 18 | useSidebar, | |
| 19 | } from "../ui/sidebar"; | |
| 20 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; | |
| 21 | import { SidebarUpdatePill } from "./SidebarUpdatePill"; | |
| 22 | ||
| 23 | export 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 | ||
| 50 | function 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 | ||
| 73 | function useSidebarStageLabel() { | |
| 74 | const primaryServerVersion = | |
| 75 | useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; | |
| 76 | ||
| 77 | return resolveSidebarStageBadgeLabel({ | |
| 78 | primaryServerVersion, | |
| 79 | fallbackStageLabel: APP_STAGE_LABEL, | |
| 80 | }); | |
| 81 | } | |
| 82 | ||
| 83 | function 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 | ||
| 99 | export 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 { |
| 4 | 4 | scopeThreadRef, |
| 5 | 5 | } from "@t3tools/client-runtime/environment"; |
| 6 | 6 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; | |
| 7 | 8 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 8 | 9 | import * as Cause from "effect/Cause"; |
| 9 | 10 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -19,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs"; |
| 19 | 20 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 20 | 21 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 21 | 22 | import { readLocalApi } from "../localApi"; |
| 22 | import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; | |
| 23 | import { | |
| 24 | readEnvironmentSupportsSettlement, | |
| 25 | readEnvironmentThreadRefs, | |
| 26 | readProject, | |
| 27 | readThreadShell, | |
| 28 | } from "../state/entities"; | |
| 23 | 29 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 24 | 30 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 25 | 31 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| ... | ... | @@ -39,6 +45,30 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass<ThreadArc |
| 39 | 45 | } |
| 40 | 46 | } |
| 41 | 47 | |
| 48 | export 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 | ||
| 60 | export 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 | ||
| 42 | 72 | export function useThreadActions() { |
| 43 | 73 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 44 | 74 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | ... | @@ -50,6 +80,12 @@ export function useThreadActions() { |
| 50 | 80 | const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { |
| 51 | 81 | reportFailure: false, |
| 52 | 82 | }); |
| 83 | const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { | |
| 84 | reportFailure: false, | |
| 85 | }); | |
| 86 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | |
| 87 | reportFailure: false, | |
| 88 | }); | |
| 53 | 89 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); |
| 54 | 90 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { |
| 55 | 91 | reportFailure: false, |
| ... | ... | @@ -345,6 +381,66 @@ export function useThreadActions() { |
| 345 | 381 | ], |
| 346 | 382 | ); |
| 347 | 383 | |
| 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 | ||
| 348 | 444 | const confirmAndDeleteThread = useCallback( |
| 349 | 445 | async (target: ScopedThreadRef) => { |
| 350 | 446 | const localApi = readLocalApi(); |
| ... | ... | @@ -379,7 +475,16 @@ export function useThreadActions() { |
| 379 | 475 | unarchiveThread, |
| 380 | 476 | deleteThread, |
| 381 | 477 | confirmAndDeleteThread, |
| 478 | settleThread, | |
| 479 | unsettleThread, | |
| 382 | 480 | }), |
| 383 | [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], | |
| 481 | [ | |
| 482 | archiveThread, | |
| 483 | confirmAndDeleteThread, | |
| 484 | deleteThread, | |
| 485 | settleThread, | |
| 486 | unarchiveThread, | |
| 487 | unsettleThread, | |
| 488 | ], | |
| 384 | 489 | ); |
| 385 | 490 | } |
apps/web/src/index.css+63-10| ... | ... | @@ -108,11 +108,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 108 | 108 | |
| 109 | 109 | @theme inline { |
| 110 | 110 | --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. */ | |
| 114 | 113 | --animate-status-pulse: status-pulse 2s infinite; |
| 115 | 114 | --animate-status-ping: status-ping 2s infinite; |
| 115 | --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; | |
| 116 | 116 | --font-sans: |
| 117 | 117 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, |
| 118 | 118 | sans-serif; |
| ... | ... | @@ -185,6 +185,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 185 | 185 | scale: 2; |
| 186 | 186 | } |
| 187 | 187 | } |
| 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 | } | |
| 188 | 203 | } |
| 189 | 204 | |
| 190 | 205 | @layer base { |
| ... | ... | @@ -232,8 +247,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 232 | 247 | } |
| 233 | 248 | |
| 234 | 249 | /* 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. */ | |
| 236 | 253 | .sidebar-stage-backdrop { |
| 254 | --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); | |
| 237 | 255 | mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); |
| 238 | 256 | -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); |
| 239 | 257 | } |
| ... | ... | @@ -246,12 +264,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 246 | 264 | to bottom, |
| 247 | 265 | transparent 0%, |
| 248 | 266 | 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% | |
| 255 | 273 | ); |
| 256 | 274 | } |
| 257 | 275 | |
| ... | ... | @@ -450,6 +468,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 450 | 468 | } |
| 451 | 469 | } |
| 452 | 470 | |
| 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 | ||
| 453 | 506 | body { |
| 454 | 507 | font-family: |
| 455 | 508 | "DM Sans Variable", |
apps/web/src/lib/chatThreadActions.test.ts+21| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | resolveNewDraftStartFromOrigin, |
| 7 | 7 | startNewLocalThreadFromContext, |
| 8 | 8 | startNewThreadFromContext, |
| 9 | startNewThreadInProjectFromContext, | |
| 9 | 10 | type ChatThreadActionContext, |
| 10 | 11 | } from "./chatThreadActions"; |
| 11 | 12 | |
| ... | ... | @@ -117,6 +118,26 @@ describe("chatThreadActions", () => { |
| 117 | 118 | }); |
| 118 | 119 | }); |
| 119 | 120 | |
| 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 | ||
| 120 | 141 | it("delegates the target environment defaults to the new-thread handler", async () => { |
| 121 | 142 | const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {}); |
| 122 | 143 |
apps/web/src/lib/chatThreadActions.ts+8| ... | ... | @@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext( |
| 75 | 75 | context: ChatThreadActionContext, |
| 76 | 76 | projectRef: ScopedProjectRef, |
| 77 | 77 | ): 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 | } | |
| 78 | 86 | await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); |
| 79 | 87 | } |
| 80 | 88 |
apps/web/src/lib/threadSort.test.ts+2| ... | ... | @@ -26,6 +26,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 26 | 26 | proposedPlans: [], |
| 27 | 27 | createdAt: "2026-03-09T10:00:00.000Z", |
| 28 | 28 | archivedAt: null, |
| 29 | settledOverride: null, | |
| 30 | settledAt: null, | |
| 29 | 31 | deletedAt: null, |
| 30 | 32 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 31 | 33 | 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. | |
| 5 | const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; | |
| 6 | ||
| 7 | export function openNewThreadPicker(): void { | |
| 8 | window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); | |
| 9 | } | |
| 10 | ||
| 11 | export 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 |
| 20 | 20 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 21 | 21 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| 22 | 22 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 23 | import { Route as SettingsBetaRouteImport } from './routes/settings.beta' | |
| 23 | 24 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 24 | 25 | import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' |
| 25 | 26 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| ... | ... | @@ -79,6 +80,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ |
| 79 | 80 | path: '/connections', |
| 80 | 81 | getParentRoute: () => SettingsRoute, |
| 81 | 82 | } as any) |
| 83 | const SettingsBetaRoute = SettingsBetaRouteImport.update({ | |
| 84 | id: '/beta', | |
| 85 | path: '/beta', | |
| 86 | getParentRoute: () => SettingsRoute, | |
| 87 | } as any) | |
| 82 | 88 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 83 | 89 | id: '/archived', |
| 84 | 90 | path: '/archived', |
| ... | ... | @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { |
| 108 | 114 | '/settings': typeof SettingsRouteWithChildren |
| 109 | 115 | '/connect/callback': typeof ConnectCallbackRoute |
| 110 | 116 | '/settings/archived': typeof SettingsArchivedRoute |
| 117 | '/settings/beta': typeof SettingsBetaRoute | |
| 111 | 118 | '/settings/connections': typeof SettingsConnectionsRoute |
| 112 | 119 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 113 | 120 | '/settings/general': typeof SettingsGeneralRoute |
| ... | ... | @@ -123,6 +130,7 @@ export interface FileRoutesByTo { |
| 123 | 130 | '/settings': typeof SettingsRouteWithChildren |
| 124 | 131 | '/connect/callback': typeof ConnectCallbackRoute |
| 125 | 132 | '/settings/archived': typeof SettingsArchivedRoute |
| 133 | '/settings/beta': typeof SettingsBetaRoute | |
| 126 | 134 | '/settings/connections': typeof SettingsConnectionsRoute |
| 127 | 135 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 128 | 136 | '/settings/general': typeof SettingsGeneralRoute |
| ... | ... | @@ -141,6 +149,7 @@ export interface FileRoutesById { |
| 141 | 149 | '/settings': typeof SettingsRouteWithChildren |
| 142 | 150 | '/connect_/callback': typeof ConnectCallbackRoute |
| 143 | 151 | '/settings/archived': typeof SettingsArchivedRoute |
| 152 | '/settings/beta': typeof SettingsBetaRoute | |
| 144 | 153 | '/settings/connections': typeof SettingsConnectionsRoute |
| 145 | 154 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 146 | 155 | '/settings/general': typeof SettingsGeneralRoute |
| ... | ... | @@ -160,6 +169,7 @@ export interface FileRouteTypes { |
| 160 | 169 | | '/settings' |
| 161 | 170 | | '/connect/callback' |
| 162 | 171 | | '/settings/archived' |
| 172 | | '/settings/beta' | |
| 163 | 173 | | '/settings/connections' |
| 164 | 174 | | '/settings/diagnostics' |
| 165 | 175 | | '/settings/general' |
| ... | ... | @@ -175,6 +185,7 @@ export interface FileRouteTypes { |
| 175 | 185 | | '/settings' |
| 176 | 186 | | '/connect/callback' |
| 177 | 187 | | '/settings/archived' |
| 188 | | '/settings/beta' | |
| 178 | 189 | | '/settings/connections' |
| 179 | 190 | | '/settings/diagnostics' |
| 180 | 191 | | '/settings/general' |
| ... | ... | @@ -192,6 +203,7 @@ export interface FileRouteTypes { |
| 192 | 203 | | '/settings' |
| 193 | 204 | | '/connect_/callback' |
| 194 | 205 | | '/settings/archived' |
| 206 | | '/settings/beta' | |
| 195 | 207 | | '/settings/connections' |
| 196 | 208 | | '/settings/diagnostics' |
| 197 | 209 | | '/settings/general' |
| ... | ... | @@ -290,6 +302,13 @@ declare module '@tanstack/react-router' { |
| 290 | 302 | preLoaderRoute: typeof SettingsConnectionsRouteImport |
| 291 | 303 | parentRoute: typeof SettingsRoute |
| 292 | 304 | } |
| 305 | '/settings/beta': { | |
| 306 | id: '/settings/beta' | |
| 307 | path: '/beta' | |
| 308 | fullPath: '/settings/beta' | |
| 309 | preLoaderRoute: typeof SettingsBetaRouteImport | |
| 310 | parentRoute: typeof SettingsRoute | |
| 311 | } | |
| 293 | 312 | '/settings/archived': { |
| 294 | 313 | id: '/settings/archived' |
| 295 | 314 | path: '/archived' |
| ... | ... | @@ -337,6 +356,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| 337 | 356 | |
| 338 | 357 | interface SettingsRouteChildren { |
| 339 | 358 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 359 | SettingsBetaRoute: typeof SettingsBetaRoute | |
| 340 | 360 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 341 | 361 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 342 | 362 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| ... | ... | @@ -347,6 +367,7 @@ interface SettingsRouteChildren { |
| 347 | 367 | |
| 348 | 368 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 349 | 369 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 370 | SettingsBetaRoute: SettingsBetaRoute, | |
| 350 | 371 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 351 | 372 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 352 | 373 | SettingsGeneralRoute: SettingsGeneralRoute, |
apps/web/src/routes/_chat.tsx+14| ... | ... | @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { useEffect } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { isCommandPaletteOpen } from "../commandPaletteContext"; |
| 6 | import { useClientSettings } from "../hooks/useSettings"; | |
| 7 | import { openNewThreadPicker } from "../newThreadPickerBus"; | |
| 8 | import { useProjects } from "../state/entities"; | |
| 6 | 9 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; |
| 7 | 10 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 8 | 11 | import { |
| ... | ... | @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { |
| 25 | 28 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = |
| 26 | 29 | useHandleNewThread(); |
| 27 | 30 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 31 | const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); | |
| 32 | const projectCount = useProjects().length; | |
| 28 | 33 | const terminalOpen = useTerminalUiStateStore((state) => |
| 29 | 34 | routeThreadRef |
| 30 | 35 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| ... | ... | @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { |
| 75 | 80 | if (command === "chat.new") { |
| 76 | 81 | event.preventDefault(); |
| 77 | 82 | 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 | } | |
| 78 | 90 | void startNewThreadFromContext({ |
| 79 | 91 | activeDraftThread, |
| 80 | 92 | activeThread: activeThread ?? undefined, |
| ... | ... | @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { |
| 140 | 152 | keybindings, |
| 141 | 153 | defaultProjectRef, |
| 142 | 154 | previewOpen, |
| 155 | projectCount, | |
| 143 | 156 | routeThreadRef, |
| 144 | 157 | selectedThreadKeysSize, |
| 158 | sidebarV2Enabled, | |
| 145 | 159 | terminalOpen, |
| 146 | 160 | ]); |
| 147 | 161 |
apps/web/src/routes/settings.beta.tsx created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; | |
| 4 | ||
| 5 | function SettingsBetaRoute() { | |
| 6 | return <BetaSettingsPanel />; | |
| 7 | } | |
| 8 | ||
| 9 | export 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 | |
| 185 | 185 | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); |
| 186 | 186 | } |
| 187 | 187 | |
| 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. */ | |
| 191 | export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { | |
| 192 | return ( | |
| 193 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 194 | .threadSettlement === true | |
| 195 | ); | |
| 196 | } | |
| 197 | ||
| 188 | 198 | export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { |
| 189 | 199 | return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); |
| 190 | 200 | } |
apps/web/src/worktreeCleanup.test.ts+2| ... | ... | @@ -26,6 +26,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 26 | 26 | createdAt: "2026-02-13T00:00:00.000Z", |
| 27 | 27 | updatedAt: "2026-02-13T00:00:00.000Z", |
| 28 | 28 | archivedAt: null, |
| 29 | settledOverride: null, | |
| 30 | settledAt: null, | |
| 29 | 31 | deletedAt: null, |
| 30 | 32 | latestTurn: null, |
| 31 | 33 | branch: null, |
packages/client-runtime/package.json+4| ... | ... | @@ -127,6 +127,10 @@ |
| 127 | 127 | "types": "./src/state/threadSort.ts", |
| 128 | 128 | "default": "./src/state/threadSort.ts" |
| 129 | 129 | }, |
| 130 | "./state/thread-settled": { | |
| 131 | "types": "./src/state/threadSettled.ts", | |
| 132 | "default": "./src/state/threadSettled.ts" | |
| 133 | }, | |
| 130 | 134 | "./state/vcs": { |
| 131 | 135 | "types": "./src/state/vcs.ts", |
| 132 | 136 | "default": "./src/state/vcs.ts" |
packages/client-runtime/src/operations/commands.test.ts+38-1| ... | ... | @@ -21,7 +21,13 @@ import { |
| 21 | 21 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 22 | 22 | import * as RpcSession from "../rpc/session.ts"; |
| 23 | 23 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 24 | import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; | |
| 24 | import { | |
| 25 | archiveThread, | |
| 26 | createProject, | |
| 27 | settleThread, | |
| 28 | stopThreadSession, | |
| 29 | unsettleThread, | |
| 30 | } from "./commands.ts"; | |
| 25 | 31 | |
| 26 | 32 | const TEST_CRYPTO_LAYER = Layer.succeed( |
| 27 | 33 | Crypto.Crypto, |
| ... | ... | @@ -134,4 +140,35 @@ describe("environment commands", () => { |
| 134 | 140 | ]); |
| 135 | 141 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 136 | 142 | ); |
| 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 | ); | |
| 137 | 174 | }); |
packages/client-runtime/src/operations/commands.ts+22| ... | ... | @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; |
| 35 | 35 | export type DeleteThreadInput = CommandInput<"thread.delete">; |
| 36 | 36 | export type ArchiveThreadInput = CommandInput<"thread.archive">; |
| 37 | 37 | export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; |
| 38 | export type SettleThreadInput = CommandInput<"thread.settle">; | |
| 39 | export type UnsettleThreadInput = CommandInput<"thread.unsettle">; | |
| 38 | 40 | export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; |
| 39 | 41 | export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; |
| 40 | 42 | export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; |
| ... | ... | @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E |
| 153 | 155 | }); |
| 154 | 156 | }); |
| 155 | 157 | |
| 158 | export 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 | ||
| 168 | export 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 | ||
| 156 | 178 | export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( |
| 157 | 179 | "EnvironmentCommands.updateThreadMetadata", |
| 158 | 180 | )(function* (input) { |
packages/client-runtime/src/state/entities.test.ts+2| ... | ... | @@ -96,6 +96,8 @@ const THREAD_SHELL = { |
| 96 | 96 | createdAt: "2026-06-01T00:00:00.000Z", |
| 97 | 97 | updatedAt: "2026-06-01T00:00:00.000Z", |
| 98 | 98 | archivedAt: null, |
| 99 | settledOverride: null, | |
| 100 | settledAt: null, | |
| 99 | 101 | session: null, |
| 100 | 102 | latestUserMessageAt: null, |
| 101 | 103 | hasPendingApprovals: false, |
packages/client-runtime/src/state/shellReducer.test.ts+2| ... | ... | @@ -36,6 +36,8 @@ const stubThread = { |
| 36 | 36 | createdAt: "2026-04-01T00:00:00.000Z", |
| 37 | 37 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 38 | 38 | archivedAt: null, |
| 39 | settledOverride: null, | |
| 40 | settledAt: null, | |
| 39 | 41 | latestUserMessageAt: null, |
| 40 | 42 | hasPendingApprovals: false, |
| 41 | 43 | hasPendingUserInput: false, |
packages/client-runtime/src/state/threadCommands.ts+18| ... | ... | @@ -12,9 +12,11 @@ import { |
| 12 | 12 | type RevertThreadCheckpointInput, |
| 13 | 13 | type SetThreadInteractionModeInput, |
| 14 | 14 | type SetThreadRuntimeModeInput, |
| 15 | type SettleThreadInput, | |
| 15 | 16 | type StartThreadTurnInput, |
| 16 | 17 | type StopThreadSessionInput, |
| 17 | 18 | type UnarchiveThreadInput, |
| 19 | type UnsettleThreadInput, | |
| 18 | 20 | type UpdateThreadMetadataInput, |
| 19 | 21 | archiveThread, |
| 20 | 22 | createThread, |
| ... | ... | @@ -25,9 +27,11 @@ import { |
| 25 | 27 | revertThreadCheckpoint, |
| 26 | 28 | setThreadInteractionMode, |
| 27 | 29 | setThreadRuntimeMode, |
| 30 | settleThread, | |
| 28 | 31 | startThreadTurn, |
| 29 | 32 | stopThreadSession, |
| 30 | 33 | unarchiveThread, |
| 34 | unsettleThread, | |
| 31 | 35 | updateThreadMetadata, |
| 32 | 36 | } from "../operations/commands.ts"; |
| 33 | 37 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | ... | @@ -42,9 +46,11 @@ export type { |
| 42 | 46 | RevertThreadCheckpointInput, |
| 43 | 47 | SetThreadInteractionModeInput, |
| 44 | 48 | SetThreadRuntimeModeInput, |
| 49 | SettleThreadInput, | |
| 45 | 50 | StartThreadTurnInput, |
| 46 | 51 | StopThreadSessionInput, |
| 47 | 52 | UnarchiveThreadInput, |
| 53 | UnsettleThreadInput, | |
| 48 | 54 | UpdateThreadMetadataInput, |
| 49 | 55 | } from "../operations/commands.ts"; |
| 50 | 56 | |
| ... | ... | @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 82 | 88 | scheduler, |
| 83 | 89 | concurrency, |
| 84 | 90 | }), |
| 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 | }), | |
| 85 | 103 | updateMetadata: createEnvironmentCommand(runtime, { |
| 86 | 104 | label: "environment-data:commands:thread:update-metadata", |
| 87 | 105 | execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), |
packages/client-runtime/src/state/threadDetail.ts+2| ... | ... | @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( |
| 57 | 57 | createdAt: shell.createdAt, |
| 58 | 58 | updatedAt: shell.updatedAt, |
| 59 | 59 | archivedAt: shell.archivedAt, |
| 60 | settledOverride: shell.settledOverride, | |
| 61 | settledAt: shell.settledAt, | |
| 60 | 62 | session: shell.session, |
| 61 | 63 | }; |
| 62 | 64 | } |
packages/client-runtime/src/state/threadReducer.test.ts+58| ... | ... | @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { |
| 34 | 34 | createdAt: "2026-04-01T00:00:00.000Z", |
| 35 | 35 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 36 | 36 | archivedAt: null, |
| 37 | settledOverride: null, | |
| 38 | settledAt: null, | |
| 37 | 39 | deletedAt: null, |
| 38 | 40 | messages: [], |
| 39 | 41 | proposedPlans: [], |
| ... | ... | @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { |
| 164 | 166 | }); |
| 165 | 167 | }); |
| 166 | 168 | |
| 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 | ||
| 167 | 225 | describe("thread.meta-updated", () => { |
| 168 | 226 | it("patches title and branch", () => { |
| 169 | 227 | const result = applyThreadDetailEvent(baseThread, { |
packages/client-runtime/src/state/threadReducer.ts+24| ... | ... | @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( |
| 72 | 72 | createdAt: event.payload.createdAt, |
| 73 | 73 | updatedAt: event.payload.updatedAt, |
| 74 | 74 | archivedAt: null, |
| 75 | settledOverride: null, | |
| 76 | settledAt: null, | |
| 75 | 77 | deletedAt: null, |
| 76 | 78 | messages: [], |
| 77 | 79 | proposedPlans: [], |
| ... | ... | @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( |
| 100 | 102 | thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, |
| 101 | 103 | }; |
| 102 | 104 | |
| 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 | ||
| 103 | 127 | // ── Thread metadata ───────────────────────────────────────────── |
| 104 | 128 | case "thread.meta-updated": |
| 105 | 129 | return { |
packages/client-runtime/src/state/threadSettled.test.ts created+345| ... | ... | @@ -0,0 +1,345 @@ |
| 1 | import { | |
| 2 | ProjectId, | |
| 3 | ProviderInstanceId, | |
| 4 | ThreadId, | |
| 5 | TurnId, | |
| 6 | type OrchestrationThreadShell, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { describe, expect, it } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { | |
| 11 | canSettle, | |
| 12 | effectiveSettled, | |
| 13 | hasQueuedTurnStart, | |
| 14 | threadLastActivityAt, | |
| 15 | type ChangeRequestStateLike, | |
| 16 | } from "./threadSettled.ts"; | |
| 17 | ||
| 18 | const NOW = "2026-04-10T00:00:00.000Z"; | |
| 19 | const FRESH = "2026-04-09T00:00:00.000Z"; | |
| 20 | const STALE = "2026-04-06T23:59:59.999Z"; | |
| 21 | ||
| 22 | function 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 | ||
| 73 | describe("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 | ||
| 94 | describe("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 | ||
| 192 | describe("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 | ||
| 263 | describe("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 @@ |
| 1 | import type { OrchestrationThreadShell } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export type ChangeRequestStateLike = "open" | "closed" | "merged"; | |
| 4 | ||
| 5 | const DAY_MS = 24 * 60 * 60 * 1_000; | |
| 6 | ||
| 7 | export 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 | */ | |
| 36 | export 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 | */ | |
| 47 | export 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 | */ | |
| 78 | export 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 | */ | |
| 101 | export 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 = { |
| 69 | 69 | createdAt: "2026-04-01T00:00:00.000Z", |
| 70 | 70 | updatedAt: "2026-04-01T00:00:00.000Z", |
| 71 | 71 | archivedAt: null, |
| 72 | settledOverride: null, | |
| 73 | settledAt: null, | |
| 72 | 74 | deletedAt: null, |
| 73 | 75 | messages: [], |
| 74 | 76 | proposedPlans: [], |
packages/contracts/src/environment.ts+4| ... | ... | @@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T |
| 23 | 23 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 24 | 24 | repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 25 | 25 | 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), | |
| 26 | 30 | }); |
| 27 | 31 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 28 | 32 |
packages/contracts/src/orchestration.test.ts+115| ... | ... | @@ -15,6 +15,8 @@ import { |
| 15 | 15 | ProjectMetaUpdatedPayload, |
| 16 | 16 | OrchestrationProposedPlan, |
| 17 | 17 | OrchestrationSession, |
| 18 | OrchestrationThread, | |
| 19 | OrchestrationThreadShell, | |
| 18 | 20 | ProjectCreateCommand, |
| 19 | 21 | ThreadMetaUpdatedPayload, |
| 20 | 22 | ThreadTurnStartCommand, |
| ... | ... | @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( |
| 37 | 39 | const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); |
| 38 | 40 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 39 | 41 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 42 | const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); | |
| 43 | const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); | |
| 40 | 44 | const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); |
| 41 | 45 | |
| 42 | 46 | function getOptionValue( |
| ... | ... | @@ -344,6 +348,75 @@ it.effect("decodes thread archive and unarchive commands", () => |
| 344 | 348 | }), |
| 345 | 349 | ); |
| 346 | 350 | |
| 351 | it.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 | ||
| 380 | it.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 | ||
| 347 | 420 | it.effect("decodes thread archived and unarchived events", () => |
| 348 | 421 | Effect.gen(function* () { |
| 349 | 422 | const archived = yield* decodeOrchestrationEvent({ |
| ... | ... | @@ -388,6 +461,48 @@ it.effect("decodes thread archived and unarchived events", () => |
| 388 | 461 | }), |
| 389 | 462 | ); |
| 390 | 463 | |
| 464 | it.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 | ||
| 391 | 506 | it.effect("accepts provider-scoped model options in thread.turn.start", () => |
| 392 | 507 | Effect.gen(function* () { |
| 393 | 508 | const parsed = yield* decodeThreadTurnStartCommand({ |
packages/contracts/src/orchestration.ts+52| ... | ... | @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ |
| 356 | 356 | createdAt: IsoDateTime, |
| 357 | 357 | updatedAt: IsoDateTime, |
| 358 | 358 | 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))), | |
| 359 | 363 | deletedAt: Schema.NullOr(IsoDateTime), |
| 360 | 364 | messages: Schema.Array(OrchestrationMessage), |
| 361 | 365 | proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( |
| ... | ... | @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ |
| 402 | 406 | createdAt: IsoDateTime, |
| 403 | 407 | updatedAt: IsoDateTime, |
| 404 | 408 | 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))), | |
| 405 | 413 | session: Schema.NullOr(OrchestrationSession), |
| 406 | 414 | latestUserMessageAt: Schema.NullOr(IsoDateTime), |
| 407 | 415 | hasPendingApprovals: Schema.Boolean, |
| ... | ... | @@ -558,6 +566,22 @@ const ThreadUnarchiveCommand = Schema.Struct({ |
| 558 | 566 | threadId: ThreadId, |
| 559 | 567 | }); |
| 560 | 568 | |
| 569 | const ThreadSettleCommand = Schema.Struct({ | |
| 570 | type: Schema.Literal("thread.settle"), | |
| 571 | commandId: CommandId, | |
| 572 | threadId: ThreadId, | |
| 573 | }); | |
| 574 | ||
| 575 | const 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 | ||
| 561 | 585 | const ThreadMetaUpdateCommand = Schema.Struct({ |
| 562 | 586 | type: Schema.Literal("thread.meta.update"), |
| 563 | 587 | commandId: CommandId, |
| ... | ... | @@ -700,6 +724,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 700 | 724 | ThreadDeleteCommand, |
| 701 | 725 | ThreadArchiveCommand, |
| 702 | 726 | ThreadUnarchiveCommand, |
| 727 | ThreadSettleCommand, | |
| 728 | ThreadUnsettleCommand, | |
| 703 | 729 | ThreadMetaUpdateCommand, |
| 704 | 730 | ThreadRuntimeModeSetCommand, |
| 705 | 731 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -721,6 +747,8 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 721 | 747 | ThreadDeleteCommand, |
| 722 | 748 | ThreadArchiveCommand, |
| 723 | 749 | ThreadUnarchiveCommand, |
| 750 | ThreadSettleCommand, | |
| 751 | ThreadUnsettleCommand, | |
| 724 | 752 | ThreadMetaUpdateCommand, |
| 725 | 753 | ThreadRuntimeModeSetCommand, |
| 726 | 754 | ThreadInteractionModeSetCommand, |
| ... | ... | @@ -823,6 +851,8 @@ export const OrchestrationEventType = Schema.Literals([ |
| 823 | 851 | "thread.deleted", |
| 824 | 852 | "thread.archived", |
| 825 | 853 | "thread.unarchived", |
| 854 | "thread.settled", | |
| 855 | "thread.unsettled", | |
| 826 | 856 | "thread.meta-updated", |
| 827 | 857 | "thread.runtime-mode-set", |
| 828 | 858 | "thread.interaction-mode-set", |
| ... | ... | @@ -902,6 +932,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ |
| 902 | 932 | updatedAt: IsoDateTime, |
| 903 | 933 | }); |
| 904 | 934 | |
| 935 | export const ThreadSettledPayload = Schema.Struct({ | |
| 936 | threadId: ThreadId, | |
| 937 | settledAt: IsoDateTime, | |
| 938 | updatedAt: IsoDateTime, | |
| 939 | }); | |
| 940 | ||
| 941 | export const ThreadUnsettledPayload = Schema.Struct({ | |
| 942 | threadId: ThreadId, | |
| 943 | reason: Schema.Literals(["user", "activity"]), | |
| 944 | updatedAt: IsoDateTime, | |
| 945 | }); | |
| 946 | ||
| 905 | 947 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 906 | 948 | threadId: ThreadId, |
| 907 | 949 | title: Schema.optional(TrimmedNonEmptyString), |
| ... | ... | @@ -1069,6 +1111,16 @@ export const OrchestrationEvent = Schema.Union([ |
| 1069 | 1111 | type: Schema.Literal("thread.unarchived"), |
| 1070 | 1112 | payload: ThreadUnarchivedPayload, |
| 1071 | 1113 | }), |
| 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 | }), | |
| 1072 | 1124 | Schema.Struct({ |
| 1073 | 1125 | ...EventBaseFields, |
| 1074 | 1126 | type: Schema.Literal("thread.meta-updated"), |
packages/contracts/src/settings.test.ts+21| ... | ... | @@ -4,12 +4,14 @@ import * as Schema from "effect/Schema"; |
| 4 | 4 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 5 | 5 | import { |
| 6 | 6 | ClientSettingsSchema, |
| 7 | ClientSettingsPatch, | |
| 7 | 8 | DEFAULT_SERVER_SETTINGS, |
| 8 | 9 | ServerSettings, |
| 9 | 10 | ServerSettingsPatch, |
| 10 | 11 | } from "./settings.ts"; |
| 11 | 12 | |
| 12 | 13 | const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); |
| 14 | const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); | |
| 13 | 15 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 14 | 16 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 15 | 17 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| ... | ... | @@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => { |
| 31 | 33 | }); |
| 32 | 34 | }); |
| 33 | 35 | |
| 36 | describe("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 | ||
| 34 | 55 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 35 | 56 | it("defaults to an empty record so legacy configs without the key still decode", () => { |
| 36 | 57 | expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); |
packages/contracts/src/settings.ts+16| ... | ... | @@ -38,6 +38,16 @@ export const SidebarThreadPreviewCount = Schema.Int.check( |
| 38 | 38 | ); |
| 39 | 39 | export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; |
| 40 | 40 | export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; |
| 41 | export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; | |
| 42 | export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; | |
| 43 | export 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 | ); | |
| 49 | export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; | |
| 50 | export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; | |
| 41 | 51 | |
| 42 | 52 | export const ClientSettingsSchema = Schema.Struct({ |
| 43 | 53 | autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | ... | @@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 72 | 82 | modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 73 | 83 | }), |
| 74 | 84 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 85 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( | |
| 86 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), | |
| 87 | ), | |
| 75 | 88 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 76 | 89 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 77 | 90 | ), |
| ... | ... | @@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 88 | 101 | sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( |
| 89 | 102 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), |
| 90 | 103 | ), |
| 104 | sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 91 | 105 | timestampFormat: TimestampFormat.pipe( |
| 92 | 106 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), |
| 93 | 107 | ), |
| ... | ... | @@ -567,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 567 | 581 | }), |
| 568 | 582 | ), |
| 569 | 583 | ), |
| 584 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), | |
| 570 | 585 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 571 | 586 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 572 | 587 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |
| ... | ... | @@ -574,6 +589,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 574 | 589 | sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), |
| 575 | 590 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), |
| 576 | 591 | sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), |
| 592 | sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), | |
| 577 | 593 | timestampFormat: Schema.optionalKey(TimestampFormat), |
| 578 | 594 | wordWrap: Schema.optionalKey(Schema.Boolean), |
| 579 | 595 | }); |