| author | |
| committer | |
| log | f1a68ac9b05c93292e677f700ae3ce7f7ac7f064 |
| tree | a302d99d246bcfee8297d017f817afd32ae826a7 |
| parent | 55dd01612efc51e19de479da5a0e348cbe2521e3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude <noreply@anthropic.com>8 files changed, 523 insertions(+), 165 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+124-108| ... | @@ -36,12 +36,13 @@ import { | ... | @@ -36,12 +36,13 @@ import { |
| 36 | ThreadListRow, | 36 | ThreadListRow, |
| 37 | ThreadListShowMoreRow, | 37 | ThreadListShowMoreRow, |
| 38 | } from "../threads/thread-list-items"; | 38 | } from "../threads/thread-list-items"; |
| 39 | import { ThreadListV2Row } from "../threads/thread-list-v2-items"; | 39 | import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; |
| 40 | import { | 40 | import { |
| 41 | buildThreadListV2Items, | 41 | buildThreadListV2Items, |
| 42 | buildThreadListV2ListItems, | ||
| 42 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, | 43 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 43 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, | 44 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 44 | type ThreadListV2Item, | 45 | type ThreadListV2ListItem, |
| 45 | } from "../threads/threadListV2"; | 46 | } from "../threads/threadListV2"; |
| 46 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; | 47 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; |
| 47 | import { | 48 | import { |
| ... | @@ -549,50 +550,100 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -549,50 +550,100 @@ export function HomeScreen(props: HomeScreenProps) { |
| 549 | // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout | 550 | // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout |
| 550 | // range) the boundary string is identical and the chain would die. | 551 | // range) the boundary string is identical and the chain would die. |
| 551 | }, [nextSnoozeWakeAt, snoozeWakeTick]); | 552 | }, [nextSnoozeWakeAt, snoozeWakeTick]); |
| 552 | const threadListV2Items = threadListV2Layout.items; | 553 | // Queued tasks are not thread shells, so the v2 partition never sees them; |
| 554 | // they are spliced in below the active block and stay visible and deletable | ||
| 555 | // while their environment is offline. Same environment scope and search | ||
| 556 | // filter as the list itself. | ||
| 557 | const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); | ||
| 558 | const v2PendingTasks = useMemo( | ||
| 559 | () => | ||
| 560 | props.pendingTasks.filter( | ||
| 561 | (pendingTask) => | ||
| 562 | (props.selectedEnvironmentId === null || | ||
| 563 | pendingTask.message.environmentId === props.selectedEnvironmentId) && | ||
| 564 | (v2ScopedProjectKeys === null || | ||
| 565 | v2ScopedProjectKeys.has( | ||
| 566 | scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), | ||
| 567 | )) && | ||
| 568 | (v2SearchQuery.length === 0 || | ||
| 569 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), | ||
| 570 | ), | ||
| 571 | [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], | ||
| 572 | ); | ||
| 573 | const threadListV2Items = useMemo( | ||
| 574 | () => | ||
| 575 | buildThreadListV2ListItems({ | ||
| 576 | items: threadListV2Layout.items, | ||
| 577 | pendingTasks: v2PendingTasks, | ||
| 578 | }), | ||
| 579 | [threadListV2Layout.items, v2PendingTasks], | ||
| 580 | ); | ||
| 553 | 581 | ||
| 554 | const renderV2Item = useCallback( | 582 | const renderV2Item = useCallback( |
| 555 | ({ item }: { readonly item: ThreadListV2Item }) => ( | 583 | ({ item }: { readonly item: ThreadListV2ListItem }) => { |
| 556 | <ThreadListV2Row | 584 | if (item.type === "v2-pending") { |
| 557 | thread={item.thread} | 585 | const pendingScopeKey = scopedProjectKey( |
| 558 | variant={item.variant} | 586 | item.pendingTask.message.environmentId, |
| 559 | showSettledDivider={item.showSettledDivider} | 587 | item.pendingTask.creation.projectId, |
| 560 | project={ | 588 | ); |
| 561 | projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? | 589 | return ( |
| 562 | null | 590 | <ThreadListV2PendingRow |
| 563 | } | 591 | pendingTask={item.pendingTask} |
| 564 | projectTitle={v2ProjectTitleByProjectKey.get( | 592 | project={projectByKey.get(pendingScopeKey) ?? null} |
| 565 | scopedProjectKey(item.thread.environmentId, item.thread.projectId), | 593 | projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)} |
| 566 | )} | 594 | environmentLabel={ |
| 567 | providerDriver={ | 595 | Object.keys(props.savedConnectionsById).length > 1 |
| 568 | serverConfigs | 596 | ? (props.savedConnectionsById[item.pendingTask.message.environmentId] |
| 569 | .get(item.thread.environmentId) | 597 | ?.environmentLabel ?? null) |
| 570 | ?.providers.find( | 598 | : null |
| 571 | (provider) => | 599 | } |
| 572 | provider.instanceId === | 600 | showPendingDivider={item.showPendingDivider} |
| 573 | (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), | 601 | onSelectPendingTask={props.onSelectPendingTask} |
| 574 | )?.driver ?? null | 602 | onDeletePendingTask={props.onDeletePendingTask} |
| 575 | } | 603 | /> |
| 576 | environmentLabel={ | 604 | ); |
| 577 | Object.keys(props.savedConnectionsById).length > 1 | 605 | } |
| 578 | ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) | 606 | const thread = item.item.thread; |
| 579 | : null | 607 | return ( |
| 580 | } | 608 | <ThreadListV2Row |
| 581 | onSelectThread={props.onSelectThread} | 609 | thread={thread} |
| 582 | onDeleteThread={handleDeleteThread} | 610 | variant={item.item.variant} |
| 583 | onArchiveThread={props.onArchiveThread} | 611 | showSettledDivider={item.item.showSettledDivider} |
| 584 | settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} | 612 | project={ |
| 585 | onSettleThread={handleSettleThread} | 613 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| 586 | onUnsettleThread={handleUnsettleThread} | 614 | } |
| 587 | onChangeRequestState={handleChangeRequestState} | 615 | projectTitle={v2ProjectTitleByProjectKey.get( |
| 588 | projectCwd={ | 616 | scopedProjectKey(thread.environmentId, thread.projectId), |
| 589 | projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? | 617 | )} |
| 590 | null | 618 | providerDriver={ |
| 591 | } | 619 | serverConfigs |
| 592 | onSwipeableClose={handleSwipeableClose} | 620 | .get(thread.environmentId) |
| 593 | onSwipeableWillOpen={handleSwipeableWillOpen} | 621 | ?.providers.find( |
| 594 | /> | 622 | (provider) => |
| 595 | ), | 623 | provider.instanceId === |
| 624 | (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), | ||
| 625 | )?.driver ?? null | ||
| 626 | } | ||
| 627 | environmentLabel={ | ||
| 628 | Object.keys(props.savedConnectionsById).length > 1 | ||
| 629 | ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) | ||
| 630 | : null | ||
| 631 | } | ||
| 632 | onSelectThread={props.onSelectThread} | ||
| 633 | onDeleteThread={handleDeleteThread} | ||
| 634 | onArchiveThread={props.onArchiveThread} | ||
| 635 | settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} | ||
| 636 | onSettleThread={handleSettleThread} | ||
| 637 | onUnsettleThread={handleUnsettleThread} | ||
| 638 | onChangeRequestState={handleChangeRequestState} | ||
| 639 | projectCwd={ | ||
| 640 | projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | ||
| 641 | } | ||
| 642 | onSwipeableClose={handleSwipeableClose} | ||
| 643 | onSwipeableWillOpen={handleSwipeableWillOpen} | ||
| 644 | /> | ||
| 645 | ); | ||
| 646 | }, | ||
| 596 | [ | 647 | [ |
| 597 | handleChangeRequestState, | 648 | handleChangeRequestState, |
| 598 | handleDeleteThread, | 649 | handleDeleteThread, |
| ... | @@ -603,6 +654,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -603,6 +654,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 603 | projectByKey, | 654 | projectByKey, |
| 604 | projectCwdByKey, | 655 | projectCwdByKey, |
| 605 | props.onArchiveThread, | 656 | props.onArchiveThread, |
| 657 | props.onDeletePendingTask, | ||
| 658 | props.onSelectPendingTask, | ||
| 606 | props.onSelectThread, | 659 | props.onSelectThread, |
| 607 | props.savedConnectionsById, | 660 | props.savedConnectionsById, |
| 608 | serverConfigs, | 661 | serverConfigs, |
| ... | @@ -610,10 +663,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -610,10 +663,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 610 | v2ProjectTitleByProjectKey, | 663 | v2ProjectTitleByProjectKey, |
| 611 | ], | 664 | ], |
| 612 | ); | 665 | ); |
| 613 | const v2KeyExtractor = useCallback( | 666 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); |
| 614 | (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, | ||
| 615 | [], | ||
| 616 | ); | ||
| 617 | 667 | ||
| 618 | const extraData = useMemo( | 668 | const extraData = useMemo( |
| 619 | () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), | 669 | () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), |
| ... | @@ -788,41 +838,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -788,41 +838,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 788 | </> | 838 | </> |
| 789 | ); | 839 | ); |
| 790 | 840 | ||
| 791 | // v2 renders queued offline tasks above the thread cards — they are not | ||
| 792 | // thread shells, so the v2 item builder never sees them, but they must | ||
| 793 | // stay visible and deletable while their environment is offline. They | ||
| 794 | // respect the same environment scope and search filter as the list. | ||
| 795 | const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); | ||
| 796 | const v2PendingTasks = props.pendingTasks.filter( | ||
| 797 | (pendingTask) => | ||
| 798 | (props.selectedEnvironmentId === null || | ||
| 799 | pendingTask.message.environmentId === props.selectedEnvironmentId) && | ||
| 800 | (v2ScopedProjectKeys === null || | ||
| 801 | v2ScopedProjectKeys.has( | ||
| 802 | scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), | ||
| 803 | )) && | ||
| 804 | (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), | ||
| 805 | ); | ||
| 806 | // Project scoping lives in the header filter menu (no inline chip row on | 841 | // Project scoping lives in the header filter menu (no inline chip row on |
| 807 | // mobile — the menu is the one filter surface). | 842 | // mobile — the menu is the one filter surface). |
| 808 | const v2ListHeader = ( | 843 | const v2ListHeader = listHeader; |
| 809 | <> | ||
| 810 | {listHeader} | ||
| 811 | {v2PendingTasks.map((pendingTask, index) => ( | ||
| 812 | <PendingTaskListRow | ||
| 813 | key={pendingTask.message.messageId} | ||
| 814 | variant="compact" | ||
| 815 | pendingTask={pendingTask} | ||
| 816 | environmentLabel={ | ||
| 817 | props.savedConnectionsById[pendingTask.message.environmentId]?.environmentLabel ?? null | ||
| 818 | } | ||
| 819 | isLast={index === v2PendingTasks.length - 1} | ||
| 820 | onSelectPendingTask={props.onSelectPendingTask} | ||
| 821 | onDeletePendingTask={props.onDeletePendingTask} | ||
| 822 | /> | ||
| 823 | ))} | ||
| 824 | </> | ||
| 825 | ); | ||
| 826 | 844 | ||
| 827 | const listEmpty = !hasResults ? ( | 845 | const listEmpty = !hasResults ? ( |
| 828 | hasSearchQuery ? ( | 846 | hasSearchQuery ? ( |
| ... | @@ -846,37 +864,33 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -846,37 +864,33 @@ export function HomeScreen(props: HomeScreenProps) { |
| 846 | // is empty. Search outranks the scope — "No results" names the actionable | 864 | // is empty. Search outranks the scope — "No results" names the actionable |
| 847 | // fact when a query is active. Snoozed threads outrank the rest: "No | 865 | // fact when a query is active. Snoozed threads outrank the rest: "No |
| 848 | // threads yet" over an inbox that is merely all-snoozed reads as data | 866 | // threads yet" over an inbox that is merely all-snoozed reads as data |
| 849 | // loss. Pending tasks render in the header, so the list showing them | 867 | // loss. |
| 850 | // isn't empty in the user's eyes. | ||
| 851 | const v2SnoozedCount = threadListV2Layout.snoozedCount; | 868 | const v2SnoozedCount = threadListV2Layout.snoozedCount; |
| 852 | const v2ListEmpty = | 869 | const v2ListEmpty = hasSearchQuery ? ( |
| 853 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( | 870 | v2SnoozedCount > 0 ? ( |
| 854 | v2SnoozedCount > 0 ? ( | 871 | // The snoozed threads already passed this search filter: "No |
| 855 | // The snoozed threads already passed this search filter: "No | 872 | // results" would claim nothing matched when matches are merely |
| 856 | // results" would claim nothing matched when matches are merely | 873 | // parked. |
| 857 | // parked. | ||
| 858 | <EmptyState | ||
| 859 | title={ | ||
| 860 | v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed` | ||
| 861 | } | ||
| 862 | detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`} | ||
| 863 | /> | ||
| 864 | ) : ( | ||
| 865 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | ||
| 866 | ) | ||
| 867 | ) : v2SnoozedCount > 0 ? ( | ||
| 868 | <EmptyState | 874 | <EmptyState |
| 869 | title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`} | 875 | title={v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`} |
| 870 | detail="Snoozed threads return when their wake time passes." | 876 | detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`} |
| 871 | /> | ||
| 872 | ) : v2ScopedProjectGroup !== null ? ( | ||
| 873 | <EmptyState | ||
| 874 | title={`No threads in ${v2ScopedProjectGroup.title}`} | ||
| 875 | detail="Choose another project or create a new task." | ||
| 876 | /> | 877 | /> |
| 877 | ) : ( | 878 | ) : ( |
| 878 | listEmpty | 879 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> |
| 879 | ); | 880 | ) |
| 881 | ) : v2SnoozedCount > 0 ? ( | ||
| 882 | <EmptyState | ||
| 883 | title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`} | ||
| 884 | detail="Snoozed threads return when their wake time passes." | ||
| 885 | /> | ||
| 886 | ) : v2ScopedProjectGroup !== null ? ( | ||
| 887 | <EmptyState | ||
| 888 | title={`No threads in ${v2ScopedProjectGroup.title}`} | ||
| 889 | detail="Choose another project or create a new task." | ||
| 890 | /> | ||
| 891 | ) : ( | ||
| 892 | listEmpty | ||
| 893 | ); | ||
| 880 | 894 | ||
| 881 | if (threadListV2Enabled) { | 895 | if (threadListV2Enabled) { |
| 882 | return ( | 896 | return ( |
| ... | @@ -888,6 +902,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -888,6 +902,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 888 | keyExtractor={v2KeyExtractor} | 902 | keyExtractor={v2KeyExtractor} |
| 889 | extraData={{ | 903 | extraData={{ |
| 890 | projectByKey, | 904 | projectByKey, |
| 905 | projectCwdByKey, | ||
| 906 | projectTitleByProjectKey: v2ProjectTitleByProjectKey, | ||
| 891 | serverConfigs, | 907 | serverConfigs, |
| 892 | savedConnectionsById: props.savedConnectionsById, | 908 | savedConnectionsById: props.savedConnectionsById, |
| 893 | }} | 909 | }} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+51-40| ... | @@ -26,7 +26,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -26,7 +26,7 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 26 | import { useProjects, useThreadShells } from "../../state/entities"; | 26 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 27 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | 27 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 28 | import { environmentServerConfigsAtom } from "../../state/server"; | 28 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 29 | import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; | 29 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 30 | import { useWorkspaceState } from "../../state/workspace"; | 30 | import { useWorkspaceState } from "../../state/workspace"; |
| 31 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 31 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 32 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 32 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | @@ -62,26 +62,21 @@ import { | ... | @@ -62,26 +62,21 @@ import { |
| 62 | ThreadListRow, | 62 | ThreadListRow, |
| 63 | ThreadListShowMoreRow, | 63 | ThreadListShowMoreRow, |
| 64 | } from "./thread-list-items"; | 64 | } from "./thread-list-items"; |
| 65 | import { ThreadListV2Row } from "./thread-list-v2-items"; | 65 | import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; |
| 66 | import { | 66 | import { |
| 67 | buildThreadListV2Items, | 67 | buildThreadListV2Items, |
| 68 | buildThreadListV2ListItems, | ||
| 68 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, | 69 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 69 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, | 70 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 70 | type ThreadListV2Item, | 71 | type ThreadListV2ListItem, |
| 71 | } from "./threadListV2"; | 72 | } from "./threadListV2"; |
| 72 | 73 | ||
| 73 | /** The sidebar list serves both lists: v1 grouped items or, when the Thread | 74 | /** The sidebar list serves both lists: v1 grouped items or, when the Thread |
| 74 | List v2 beta is on, queued offline tasks, flat v2 rows, and a settled | 75 | List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled |
| 75 | "Show more" pager. */ | 76 | "Show more" pager. */ |
| 76 | type SidebarListItem = | 77 | type SidebarListItem = |
| 77 | | HomeListItem | 78 | | HomeListItem |
| 78 | | { | 79 | | ThreadListV2ListItem |
| 79 | readonly type: "v2-pending-task"; | ||
| 80 | readonly key: string; | ||
| 81 | readonly pendingTask: PendingNewTask; | ||
| 82 | readonly isLast: boolean; | ||
| 83 | } | ||
| 84 | | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } | ||
| 85 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; | 80 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; |
| 86 | 81 | ||
| 87 | /** | 82 | /** |
| ... | @@ -473,11 +468,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -473,11 +468,11 @@ function ThreadNavigationSidebarPane( |
| 473 | }, [nextSnoozeWakeAt, snoozeWakeTick]); | 468 | }, [nextSnoozeWakeAt, snoozeWakeTick]); |
| 474 | const listItems = useMemo<readonly SidebarListItem[]>(() => { | 469 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 475 | if (!threadListV2Enabled) return listLayout.items; | 470 | if (!threadListV2Enabled) return listLayout.items; |
| 476 | // Queued offline tasks render above the thread rows (mirrors the | 471 | // Queued offline tasks are not thread shells, so the v2 item builder |
| 477 | // compact Home v2 list): they are not thread shells, so the v2 item | 472 | // never sees them; the shared splice puts them below the active block |
| 478 | // builder never sees them, but they must stay visible and deletable | 473 | // (mirrors the compact Home v2 list) where they stay visible and |
| 479 | // while their environment is offline. Same environment scope and | 474 | // deletable while their environment is offline. Same environment scope |
| 480 | // search filter as the list. | 475 | // and search filter as the list. |
| 481 | const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); | 476 | const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); |
| 482 | const v2PendingTasks = pendingTasks.filter( | 477 | const v2PendingTasks = pendingTasks.filter( |
| 483 | (pendingTask) => | 478 | (pendingTask) => |
| ... | @@ -490,19 +485,10 @@ function ThreadNavigationSidebarPane( | ... | @@ -490,19 +485,10 @@ function ThreadNavigationSidebarPane( |
| 490 | (v2SearchQuery.length === 0 || | 485 | (v2SearchQuery.length === 0 || |
| 491 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), | 486 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), |
| 492 | ); | 487 | ); |
| 493 | const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ | 488 | const items: SidebarListItem[] = buildThreadListV2ListItems({ |
| 494 | type: "v2-pending-task" as const, | 489 | items: threadListV2Layout.items, |
| 495 | key: `v2-pending:${pendingTask.message.messageId}`, | 490 | pendingTasks: v2PendingTasks, |
| 496 | pendingTask, | 491 | }); |
| 497 | isLast: index === v2PendingTasks.length - 1, | ||
| 498 | })); | ||
| 499 | for (const item of threadListV2Layout.items) { | ||
| 500 | items.push({ | ||
| 501 | type: "v2-thread" as const, | ||
| 502 | key: scopedThreadKey(item.thread.environmentId, item.thread.id), | ||
| 503 | item, | ||
| 504 | }); | ||
| 505 | } | ||
| 506 | if (threadListV2Layout.hiddenSettledCount > 0) { | 492 | if (threadListV2Layout.hiddenSettledCount > 0) { |
| 507 | items.push({ | 493 | items.push({ |
| 508 | type: "v2-show-more", | 494 | type: "v2-show-more", |
| ... | @@ -690,13 +676,26 @@ function ThreadNavigationSidebarPane( | ... | @@ -690,13 +676,26 @@ function ThreadNavigationSidebarPane( |
| 690 | onScroll: handleScroll, | 676 | onScroll: handleScroll, |
| 691 | onScrollBeginDrag: handleScrollBeginDrag, | 677 | onScrollBeginDrag: handleScrollBeginDrag, |
| 692 | }); | 678 | }); |
| 679 | // Project shells load after the first rows draw, so the maps they feed have | ||
| 680 | // to bust the recycler's memoization — otherwise a row keeps the blank | ||
| 681 | // favicon and fallback title it was first rendered with. | ||
| 693 | const listExtraData = useMemo( | 682 | const listExtraData = useMemo( |
| 694 | () => ({ | 683 | () => ({ |
| 695 | selectedThreadKey: props.selectedThreadKey ?? "", | 684 | selectedThreadKey: props.selectedThreadKey ?? "", |
| 685 | projectByKey, | ||
| 686 | projectCwdByKey, | ||
| 687 | projectTitleByProjectKey, | ||
| 696 | savedConnectionsById, | 688 | savedConnectionsById, |
| 697 | serverConfigs, | 689 | serverConfigs, |
| 698 | }), | 690 | }), |
| 699 | [props.selectedThreadKey, savedConnectionsById, serverConfigs], | 691 | [ |
| 692 | props.selectedThreadKey, | ||
| 693 | projectByKey, | ||
| 694 | projectCwdByKey, | ||
| 695 | projectTitleByProjectKey, | ||
| 696 | savedConnectionsById, | ||
| 697 | serverConfigs, | ||
| 698 | ], | ||
| 700 | ); | 699 | ); |
| 701 | const sidebarItemsAreEqual = useCallback( | 700 | const sidebarItemsAreEqual = useCallback( |
| 702 | (previous: SidebarListItem, item: SidebarListItem): boolean => { | 701 | (previous: SidebarListItem, item: SidebarListItem): boolean => { |
| ... | @@ -711,16 +710,19 @@ function ThreadNavigationSidebarPane( | ... | @@ -711,16 +710,19 @@ function ThreadNavigationSidebarPane( |
| 711 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { | 710 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| 712 | return previous.hiddenCount === item.hiddenCount; | 711 | return previous.hiddenCount === item.hiddenCount; |
| 713 | } | 712 | } |
| 714 | if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { | 713 | if (previous.type === "v2-pending" && item.type === "v2-pending") { |
| 715 | return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; | 714 | return ( |
| 715 | previous.pendingTask === item.pendingTask && | ||
| 716 | previous.showPendingDivider === item.showPendingDivider | ||
| 717 | ); | ||
| 716 | } | 718 | } |
| 717 | if ( | 719 | if ( |
| 718 | previous.type === "v2-thread" || | 720 | previous.type === "v2-thread" || |
| 719 | previous.type === "v2-show-more" || | 721 | previous.type === "v2-show-more" || |
| 720 | previous.type === "v2-pending-task" || | 722 | previous.type === "v2-pending" || |
| 721 | item.type === "v2-thread" || | 723 | item.type === "v2-thread" || |
| 722 | item.type === "v2-show-more" || | 724 | item.type === "v2-show-more" || |
| 723 | item.type === "v2-pending-task" | 725 | item.type === "v2-pending" |
| 724 | ) { | 726 | ) { |
| 725 | return false; | 727 | return false; |
| 726 | } | 728 | } |
| ... | @@ -748,20 +750,29 @@ function ThreadNavigationSidebarPane( | ... | @@ -748,20 +750,29 @@ function ThreadNavigationSidebarPane( |
| 748 | const renderListItem = useCallback( | 750 | const renderListItem = useCallback( |
| 749 | ({ item }: { readonly item: SidebarListItem }) => { | 751 | ({ item }: { readonly item: SidebarListItem }) => { |
| 750 | switch (item.type) { | 752 | switch (item.type) { |
| 751 | case "v2-pending-task": | 753 | case "v2-pending": { |
| 754 | const pendingScopeKey = scopedProjectKey( | ||
| 755 | item.pendingTask.message.environmentId, | ||
| 756 | item.pendingTask.creation.projectId, | ||
| 757 | ); | ||
| 752 | return ( | 758 | return ( |
| 753 | <PendingTaskListRow | 759 | <ThreadListV2PendingRow |
| 754 | variant="sidebar" | ||
| 755 | pendingTask={item.pendingTask} | 760 | pendingTask={item.pendingTask} |
| 761 | project={projectByKey.get(pendingScopeKey) ?? null} | ||
| 762 | projectTitle={projectTitleByProjectKey.get(pendingScopeKey)} | ||
| 756 | environmentLabel={ | 763 | environmentLabel={ |
| 757 | savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? | 764 | Object.keys(savedConnectionsById).length > 1 |
| 758 | null | 765 | ? (savedConnectionsById[item.pendingTask.message.environmentId] |
| 766 | ?.environmentLabel ?? null) | ||
| 767 | : null | ||
| 759 | } | 768 | } |
| 760 | isLast={item.isLast} | 769 | pane="sidebar" |
| 770 | showPendingDivider={item.showPendingDivider} | ||
| 761 | onSelectPendingTask={openPendingTask} | 771 | onSelectPendingTask={openPendingTask} |
| 762 | onDeletePendingTask={confirmDeletePendingTask} | 772 | onDeletePendingTask={confirmDeletePendingTask} |
| 763 | /> | 773 | /> |
| 764 | ); | 774 | ); |
| 775 | } | ||
| 765 | case "v2-thread": { | 776 | case "v2-thread": { |
| 766 | const thread = item.item.thread; | 777 | const thread = item.item.thread; |
| 767 | const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); | 778 | const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+123-3| ... | @@ -14,6 +14,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; | ... | @@ -14,6 +14,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; |
| 14 | import { cn } from "../../lib/cn"; | 14 | import { cn } from "../../lib/cn"; |
| 15 | import { relativeTime } from "../../lib/time"; | 15 | import { relativeTime } from "../../lib/time"; |
| 16 | import { useThemeColor } from "../../lib/useThemeColor"; | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 17 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 17 | import { useThreadPr } from "../../state/use-thread-pr"; | 18 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 18 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 19 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 19 | import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; | 20 | import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; |
| ... | @@ -68,7 +69,9 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ | ... | @@ -68,7 +69,9 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ |
| 68 | /** Rounded-row radius shared with the v1 sidebar rows. */ | 69 | /** Rounded-row radius shared with the v1 sidebar rows. */ |
| 69 | const SIDEBAR_V2_ROW_RADIUS = 12; | 70 | const SIDEBAR_V2_ROW_RADIUS = 12; |
| 70 | 71 | ||
| 71 | export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { | 72 | /** Section label + rule: the only structure in an otherwise flat list. */ |
| 73 | export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { | ||
| 74 | readonly label: string; | ||
| 72 | readonly pane?: "screen" | "sidebar"; | 75 | readonly pane?: "screen" | "sidebar"; |
| 73 | }) { | 76 | }) { |
| 74 | const borderColor = useThemeColor("--color-border"); | 77 | const borderColor = useThemeColor("--color-border"); |
| ... | @@ -79,12 +82,127 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid | ... | @@ -79,12 +82,127 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid |
| 79 | props.pane === "sidebar" ? "px-3" : "px-5", | 82 | props.pane === "sidebar" ? "px-3" : "px-5", |
| 80 | )} | 83 | )} |
| 81 | > | 84 | > |
| 82 | <Text className="text-xs font-t3-medium text-foreground-tertiary">Settled</Text> | 85 | <Text className="text-xs font-t3-medium text-foreground-tertiary">{props.label}</Text> |
| 83 | <View className="h-px flex-1" style={{ backgroundColor: borderColor }} /> | 86 | <View className="h-px flex-1" style={{ backgroundColor: borderColor }} /> |
| 84 | </View> | 87 | </View> |
| 85 | ); | 88 | ); |
| 86 | }); | 89 | }); |
| 87 | 90 | ||
| 91 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | ||
| 92 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | ||
| 93 | ]; | ||
| 94 | |||
| 95 | /** | ||
| 96 | * A queued new task, in the same idiom as an active v2 row: it is work the | ||
| 97 | * user wrote, so it reads like the threads it will become. "Queued" takes | ||
| 98 | * the status slot — the state is the one thing that differs — and stays | ||
| 99 | * uncolored because nothing is asked of the user; the environment is simply | ||
| 100 | * not reachable yet. | ||
| 101 | */ | ||
| 102 | export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { | ||
| 103 | readonly pendingTask: PendingNewTask; | ||
| 104 | readonly project: EnvironmentProject | null; | ||
| 105 | readonly projectTitle?: string; | ||
| 106 | readonly environmentLabel: string | null; | ||
| 107 | readonly pane?: "screen" | "sidebar"; | ||
| 108 | /** Draws the "Pending" divider above the first queued row. */ | ||
| 109 | readonly showPendingDivider: boolean; | ||
| 110 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | ||
| 111 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | ||
| 112 | }) { | ||
| 113 | const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; | ||
| 114 | const drawerColor = useThemeColor("--color-drawer"); | ||
| 115 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | ||
| 116 | const sidebarPane = props.pane === "sidebar"; | ||
| 117 | const projectTitle = | ||
| 118 | props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; | ||
| 119 | const branch = pendingTask.creation.branch; | ||
| 120 | |||
| 121 | const handleMenuAction = useCallback( | ||
| 122 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | ||
| 123 | if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); | ||
| 124 | }, | ||
| 125 | [onDeletePendingTask, pendingTask], | ||
| 126 | ); | ||
| 127 | |||
| 128 | const rowContent = ( | ||
| 129 | <> | ||
| 130 | <View className="flex-row items-center gap-1.5"> | ||
| 131 | {props.project ? ( | ||
| 132 | <ProjectFavicon | ||
| 133 | environmentId={pendingTask.message.environmentId} | ||
| 134 | size={15} | ||
| 135 | projectTitle={projectTitle} | ||
| 136 | workspaceRoot={props.project.workspaceRoot} | ||
| 137 | /> | ||
| 138 | ) : null} | ||
| 139 | <Text className="flex-1 text-sm font-t3-medium text-foreground-muted" numberOfLines={1}> | ||
| 140 | {projectTitle} | ||
| 141 | </Text> | ||
| 142 | <Text className="text-xs text-foreground-tertiary">Queued</Text> | ||
| 143 | </View> | ||
| 144 | {/* One line, unlike the two an active row allows: a queued title is | ||
| 145 | derived from the whole prompt rather than written as a title, so the | ||
| 146 | second line is usually a stray word or emoji rather than meaning. */} | ||
| 147 | <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={1}> | ||
| 148 | {pendingTask.title} | ||
| 149 | </Text> | ||
| 150 | {branch || props.environmentLabel ? ( | ||
| 151 | <Text className="mt-1 text-xs text-foreground-muted" numberOfLines={1}> | ||
| 152 | {branch ? ( | ||
| 153 | <Text className="text-xs text-foreground-muted" style={{ fontFamily: MONO_FONT }}> | ||
| 154 | {branch} | ||
| 155 | </Text> | ||
| 156 | ) : null} | ||
| 157 | {branch && props.environmentLabel ? " · " : null} | ||
| 158 | {props.environmentLabel ? ( | ||
| 159 | <Text className="text-xs text-foreground-tertiary">{props.environmentLabel}</Text> | ||
| 160 | ) : null} | ||
| 161 | </Text> | ||
| 162 | ) : null} | ||
| 163 | </> | ||
| 164 | ); | ||
| 165 | |||
| 166 | return ( | ||
| 167 | <> | ||
| 168 | {props.showPendingDivider ? ( | ||
| 169 | <ThreadListV2SectionDivider label="Pending" pane={props.pane} /> | ||
| 170 | ) : null} | ||
| 171 | <ControlPillMenu | ||
| 172 | actions={PENDING_TASK_MENU_ACTIONS} | ||
| 173 | onPressAction={handleMenuAction} | ||
| 174 | shouldOpenOnLongPress | ||
| 175 | > | ||
| 176 | <Pressable | ||
| 177 | accessibilityHint="Opens the queued task for editing" | ||
| 178 | accessibilityLabel={pendingTask.title} | ||
| 179 | accessibilityRole="button" | ||
| 180 | onPress={() => onSelectPendingTask(pendingTask)} | ||
| 181 | style={ | ||
| 182 | sidebarPane | ||
| 183 | ? ({ pressed }) => ({ | ||
| 184 | backgroundColor: pressed ? pressedBackgroundColor : drawerColor, | ||
| 185 | borderRadius: SIDEBAR_V2_ROW_RADIUS, | ||
| 186 | paddingHorizontal: 12, | ||
| 187 | paddingVertical: 10, | ||
| 188 | }) | ||
| 189 | : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) | ||
| 190 | } | ||
| 191 | > | ||
| 192 | {sidebarPane ? ( | ||
| 193 | rowContent | ||
| 194 | ) : ( | ||
| 195 | <View className="bg-screen"> | ||
| 196 | <View className="px-5 py-2.5">{rowContent}</View> | ||
| 197 | <View className="ml-5 h-px bg-border-subtle" /> | ||
| 198 | </View> | ||
| 199 | )} | ||
| 200 | </Pressable> | ||
| 201 | </ControlPillMenu> | ||
| 202 | </> | ||
| 203 | ); | ||
| 204 | }); | ||
| 205 | |||
| 88 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | 206 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 89 | readonly thread: EnvironmentThreadShell; | 207 | readonly thread: EnvironmentThreadShell; |
| 90 | readonly variant: "card" | "slim"; | 208 | readonly variant: "card" | "slim"; |
| ... | @@ -423,7 +541,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -423,7 +541,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 423 | 541 | ||
| 424 | return ( | 542 | return ( |
| 425 | <> | 543 | <> |
| 426 | {props.showSettledDivider ? <ThreadListV2SettledDivider pane={props.pane} /> : null} | 544 | {props.showSettledDivider ? ( |
| 545 | <ThreadListV2SectionDivider label="Settled" pane={props.pane} /> | ||
| 546 | ) : null} | ||
| 427 | <ThreadSwipeable | 547 | <ThreadSwipeable |
| 428 | backgroundColor={sidebarPane ? drawerColor : screenColor} | 548 | backgroundColor={sidebarPane ? drawerColor : screenColor} |
| 429 | compactActions={variant === "slim"} | 549 | compactActions={variant === "slim"} |
apps/mobile/src/features/threads/threadListV2.test.ts+96-1| ... | @@ -1,9 +1,19 @@ | ... | @@ -1,9 +1,19 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; | 2 | import { |
| 3 | CommandId, | ||
| 4 | EnvironmentId, | ||
| 5 | MessageId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | TurnId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 12 | ||
| 13 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 5 | import { | 14 | import { |
| 6 | buildThreadListV2Items, | 15 | buildThreadListV2Items, |
| 16 | buildThreadListV2ListItems, | ||
| 7 | resolveThreadListV2Enabled, | 17 | resolveThreadListV2Enabled, |
| 8 | resolveThreadListV2Status, | 18 | resolveThreadListV2Status, |
| 9 | sortThreadsForListV2, | 19 | sortThreadsForListV2, |
| ... | @@ -340,3 +350,88 @@ describe("buildThreadListV2Items settled paging", () => { | ... | @@ -340,3 +350,88 @@ describe("buildThreadListV2Items settled paging", () => { |
| 340 | ]); | 350 | ]); |
| 341 | }); | 351 | }); |
| 342 | }); | 352 | }); |
| 353 | |||
| 354 | function makePendingTask(id: string): PendingNewTask { | ||
| 355 | return { | ||
| 356 | message: { | ||
| 357 | environmentId, | ||
| 358 | threadId: ThreadId.make(`thread-${id}`), | ||
| 359 | messageId: MessageId.make(id), | ||
| 360 | commandId: CommandId.make(`command-${id}`), | ||
| 361 | text: id, | ||
| 362 | attachments: [], | ||
| 363 | createdAt: NOW, | ||
| 364 | creation: { | ||
| 365 | projectId: ProjectId.make("project-1"), | ||
| 366 | workspaceMode: "worktree", | ||
| 367 | branch: null, | ||
| 368 | worktreePath: null, | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | creation: { | ||
| 372 | projectId: ProjectId.make("project-1"), | ||
| 373 | workspaceMode: "worktree", | ||
| 374 | branch: null, | ||
| 375 | worktreePath: null, | ||
| 376 | }, | ||
| 377 | title: id, | ||
| 378 | }; | ||
| 379 | } | ||
| 380 | |||
| 381 | describe("buildThreadListV2ListItems", () => { | ||
| 382 | const layout = buildThreadListV2Items({ | ||
| 383 | threads: [ | ||
| 384 | makeThread({ id: ThreadId.make("active"), title: "active" }), | ||
| 385 | makeThread({ | ||
| 386 | id: ThreadId.make("settled"), | ||
| 387 | title: "settled", | ||
| 388 | settledOverride: "settled", | ||
| 389 | settledAt: NOW, | ||
| 390 | }), | ||
| 391 | ], | ||
| 392 | environmentId: null, | ||
| 393 | searchQuery: "", | ||
| 394 | now: NOW, | ||
| 395 | }); | ||
| 396 | |||
| 397 | it("splices queued tasks between the active block and the settled tail", () => { | ||
| 398 | const items = buildThreadListV2ListItems({ | ||
| 399 | items: layout.items, | ||
| 400 | pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], | ||
| 401 | }); | ||
| 402 | |||
| 403 | expect( | ||
| 404 | items.map((item) => | ||
| 405 | item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, | ||
| 406 | ), | ||
| 407 | ).toEqual(["active", "queued-1", "queued-2", "settled"]); | ||
| 408 | // Only the leading queued row labels the section, exactly like Settled. | ||
| 409 | expect( | ||
| 410 | items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), | ||
| 411 | ).toHaveLength(1); | ||
| 412 | }); | ||
| 413 | |||
| 414 | it("ends the list with queued tasks when nothing has settled yet", () => { | ||
| 415 | const activeOnly = buildThreadListV2Items({ | ||
| 416 | threads: [makeThread({ id: ThreadId.make("active"), title: "active" })], | ||
| 417 | environmentId: null, | ||
| 418 | searchQuery: "", | ||
| 419 | now: NOW, | ||
| 420 | }); | ||
| 421 | const items = buildThreadListV2ListItems({ | ||
| 422 | items: activeOnly.items, | ||
| 423 | pendingTasks: [makePendingTask("queued-1")], | ||
| 424 | }); | ||
| 425 | |||
| 426 | expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); | ||
| 427 | }); | ||
| 428 | |||
| 429 | it("leaves the thread order untouched when nothing is queued", () => { | ||
| 430 | const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); | ||
| 431 | |||
| 432 | expect(items.map((item) => item.key)).toEqual([ | ||
| 433 | `v2-thread:${environmentId}:active`, | ||
| 434 | `v2-thread:${environmentId}:settled`, | ||
| 435 | ]); | ||
| 436 | }); | ||
| 437 | }); |
apps/mobile/src/features/threads/threadListV2.ts+56| ... | @@ -2,6 +2,8 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/stat | ... | @@ -2,6 +2,8 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/stat |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 6 | |||
| 5 | /** | 7 | /** |
| 6 | * Thread List v2 model, ported from the web sidebar v2 | 8 | * Thread List v2 model, ported from the web sidebar v2 |
| 7 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). | 9 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). |
| ... | @@ -113,6 +115,60 @@ export interface ThreadListV2Layout { | ... | @@ -113,6 +115,60 @@ export interface ThreadListV2Layout { |
| 113 | readonly nextSnoozeWakeAt: string | null; | 115 | readonly nextSnoozeWakeAt: string | null; |
| 114 | } | 116 | } |
| 115 | 117 | ||
| 118 | export interface ThreadListV2ThreadListItem { | ||
| 119 | readonly type: "v2-thread"; | ||
| 120 | readonly key: string; | ||
| 121 | readonly item: ThreadListV2Item; | ||
| 122 | } | ||
| 123 | |||
| 124 | export interface ThreadListV2PendingListItem { | ||
| 125 | readonly type: "v2-pending"; | ||
| 126 | readonly key: string; | ||
| 127 | readonly pendingTask: PendingNewTask; | ||
| 128 | /** First queued row after the active block draws the PENDING divider. */ | ||
| 129 | readonly showPendingDivider: boolean; | ||
| 130 | } | ||
| 131 | |||
| 132 | export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem; | ||
| 133 | |||
| 134 | /** | ||
| 135 | * Splices queued tasks between the active block and the settled tail, so the | ||
| 136 | * list reads active → pending → settled. Queued work sits below the live | ||
| 137 | * threads because nothing can happen to it until its environment returns: | ||
| 138 | * it is waiting, not asking. Shared by the compact Home list and the iPad | ||
| 139 | * sidebar so both order and label the sections identically. | ||
| 140 | */ | ||
| 141 | export function buildThreadListV2ListItems(input: { | ||
| 142 | readonly items: ReadonlyArray<ThreadListV2Item>; | ||
| 143 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | ||
| 144 | }): ThreadListV2ListItem[] { | ||
| 145 | const threadItems = input.items.map( | ||
| 146 | (item): ThreadListV2ListItem => ({ | ||
| 147 | type: "v2-thread", | ||
| 148 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, | ||
| 149 | item, | ||
| 150 | }), | ||
| 151 | ); | ||
| 152 | if (input.pendingTasks.length === 0) return threadItems; | ||
| 153 | |||
| 154 | const pendingItems = input.pendingTasks.map( | ||
| 155 | (pendingTask, index): ThreadListV2ListItem => ({ | ||
| 156 | type: "v2-pending", | ||
| 157 | key: `v2-pending:${pendingTask.message.messageId}`, | ||
| 158 | pendingTask, | ||
| 159 | showPendingDivider: index === 0, | ||
| 160 | }), | ||
| 161 | ); | ||
| 162 | // The settled tail begins at the row that draws the SETTLED divider; with | ||
| 163 | // no settled rows the queued block simply ends the list. | ||
| 164 | const settledStart = threadItems.findIndex( | ||
| 165 | (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, | ||
| 166 | ); | ||
| 167 | return settledStart === -1 | ||
| 168 | ? [...threadItems, ...pendingItems] | ||
| 169 | : [...threadItems.slice(0, settledStart), ...pendingItems, ...threadItems.slice(settledStart)]; | ||
| 170 | } | ||
| 171 | |||
| 116 | /** | 172 | /** |
| 117 | * Partitions visible threads into the active card block (creation order) and | 173 | * Partitions visible threads into the active card block (creation order) and |
| 118 | * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` | 174 | * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` |
scripts/mobile-showcase-environment.ts+51-11| ... | @@ -201,16 +201,42 @@ export const SHOWCASE_THREADS = [ | ... | @@ -201,16 +201,42 @@ export const SHOWCASE_THREADS = [ |
| 201 | response: | 201 | response: |
| 202 | "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", | 202 | "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", |
| 203 | }, | 203 | }, |
| 204 | // Finished work, settled by hand: the list keeps it as a receded tail so | ||
| 205 | // the active block above reads as everything still in flight. The active | ||
| 206 | // block stays small enough that the settled tail begins above the fold — | ||
| 207 | // a store screenshot has to show that history exists, not just imply it. | ||
| 204 | { | 208 | { |
| 205 | id: "scheduler-breathe", | 209 | id: "handoff-haptics", |
| 210 | projectId: "t3code", | ||
| 211 | title: "Tune the handoff haptics", | ||
| 212 | branch: "feat/handoff-haptics", | ||
| 213 | minutesAgo: 5 * 60, | ||
| 214 | settled: true, | ||
| 215 | request: "Give the desktop-to-phone handoff a haptic that lands with the animation.", | ||
| 216 | response: | ||
| 217 | "The handoff now taps once as the thread lands and stays silent on failure, so the phone never celebrates a handoff that did not happen.", | ||
| 218 | }, | ||
| 219 | { | ||
| 220 | id: "streaming-shell", | ||
| 221 | projectId: "react", | ||
| 222 | title: "Stream the shell before the data", | ||
| 223 | branch: "feat/streaming-shell", | ||
| 224 | minutesAgo: 28 * 60, | ||
| 225 | settled: true, | ||
| 226 | request: "Get the app shell painted before any data request resolves.", | ||
| 227 | response: | ||
| 228 | "The shell now flushes on first byte and the data boundaries hydrate underneath it, so the first paint no longer waits on the slowest query.", | ||
| 229 | }, | ||
| 230 | { | ||
| 231 | id: "quieter-oom", | ||
| 206 | projectId: "linux", | 232 | projectId: "linux", |
| 207 | title: "Let the scheduler breathe", | 233 | title: "Make the OOM killer explain itself", |
| 208 | branch: "perf/scheduler-breathe", | 234 | branch: "feat/quieter-oom", |
| 209 | minutesAgo: 76, | 235 | minutesAgo: 2 * 24 * 60, |
| 210 | request: | 236 | settled: true, |
| 211 | "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.", | 237 | request: "Make out-of-memory kills legible without adding a single allocation to the hot path.", |
| 212 | response: | 238 | response: |
| 213 | "The new heuristic reduces needless migrations during short bursts while preserving the existing latency guardrails.", | 239 | "Kills now report the winning heuristic and the runner-up alongside the usual dump, assembled entirely from data the path already had.", |
| 214 | }, | 240 | }, |
| 215 | ] as const; | 241 | ] as const; |
| 216 | 242 | ||
| ... | @@ -300,6 +326,7 @@ function insertThread( | ... | @@ -300,6 +326,7 @@ function insertThread( |
| 300 | readonly branch: string; | 326 | readonly branch: string; |
| 301 | readonly minutesAgo: number; | 327 | readonly minutesAgo: number; |
| 302 | readonly state?: "working" | "approval" | "plan"; | 328 | readonly state?: "working" | "approval" | "plan"; |
| 329 | readonly settled?: boolean; | ||
| 303 | readonly workspaceRoot: string; | 330 | readonly workspaceRoot: string; |
| 304 | }, | 331 | }, |
| 305 | ): void { | 332 | ): void { |
| ... | @@ -312,8 +339,8 @@ function insertThread( | ... | @@ -312,8 +339,8 @@ function insertThread( |
| 312 | thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, | 339 | thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, |
| 313 | branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, | 340 | branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, |
| 314 | pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, | 341 | pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, |
| 315 | archived_at, deleted_at | 342 | archived_at, deleted_at, settled_override, settled_at |
| 316 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`, | 343 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL, ?, ?)`, |
| 317 | ) | 344 | ) |
| 318 | .run( | 345 | .run( |
| 319 | input.id, | 346 | input.id, |
| ... | @@ -330,6 +357,8 @@ function insertThread( | ... | @@ -330,6 +357,8 @@ function insertThread( |
| 330 | input.state === "plan" ? 1 : 0, | 357 | input.state === "plan" ? 1 : 0, |
| 331 | minutesBefore(now, input.minutesAgo + 120), | 358 | minutesBefore(now, input.minutesAgo + 120), |
| 332 | updatedAt, | 359 | updatedAt, |
| 360 | input.settled ? "settled" : null, | ||
| 361 | input.settled ? updatedAt : null, | ||
| 333 | ); | 362 | ); |
| 334 | database | 363 | database |
| 335 | .prepare( | 364 | .prepare( |
| ... | @@ -365,7 +394,11 @@ function seedDatabase( | ... | @@ -365,7 +394,11 @@ function seedDatabase( |
| 365 | threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, | 394 | threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, |
| 366 | now: number, | 395 | now: number, |
| 367 | ): void { | 396 | ): void { |
| 368 | const database = new NodeSqlite.DatabaseSync(dbPath); | 397 | // The environment server is already running against this file and keeps |
| 398 | // writing (migrations, projections) while we seed, so the write lock is | ||
| 399 | // genuinely contended — without a busy timeout `BEGIN IMMEDIATE` fails | ||
| 400 | // instantly with SQLITE_BUSY on a loaded machine. | ||
| 401 | const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); | ||
| 369 | try { | 402 | try { |
| 370 | database.exec("BEGIN IMMEDIATE"); | 403 | database.exec("BEGIN IMMEDIATE"); |
| 371 | for (const table of [ | 404 | for (const table of [ |
| ... | @@ -506,7 +539,14 @@ function seedDatabase( | ... | @@ -506,7 +539,14 @@ function seedDatabase( |
| 506 | } | 539 | } |
| 507 | database.exec("COMMIT"); | 540 | database.exec("COMMIT"); |
| 508 | } catch (error) { | 541 | } catch (error) { |
| 509 | database.exec("ROLLBACK"); | 542 | // A failed BEGIN (or an error SQLite already auto-rolled back) leaves no |
| 543 | // transaction, and the rollback's own "cannot rollback" error would then | ||
| 544 | // replace the one that actually explains the failure. | ||
| 545 | try { | ||
| 546 | database.exec("ROLLBACK"); | ||
| 547 | } catch { | ||
| 548 | // Nothing to roll back. | ||
| 549 | } | ||
| 510 | throw error; | 550 | throw error; |
| 511 | } finally { | 551 | } finally { |
| 512 | database.close(); | 552 | database.close(); |
scripts/mobile-showcase.test.ts+16-1| ... | @@ -272,8 +272,23 @@ it("seeds a playful multi-environment project spectrum", () => { | ... | @@ -272,8 +272,23 @@ it("seeds a playful multi-environment project spectrum", () => { |
| 272 | SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), | 272 | SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), |
| 273 | ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], | 273 | ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], |
| 274 | ); | 274 | ); |
| 275 | assert.equal(SHOWCASE_THREADS.length, 6); | 275 | assert.equal(SHOWCASE_THREADS.length, 8); |
| 276 | assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); | 276 | assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); |
| 277 | // Every project contributes to both the active block and the settled tail, | ||
| 278 | // so each list scope screenshots with the same two-part structure. | ||
| 279 | for (const project of SHOWCASE_PROJECTS) { | ||
| 280 | const projectThreads = SHOWCASE_THREADS.filter((thread) => thread.projectId === project.id); | ||
| 281 | assert.equal( | ||
| 282 | projectThreads.some((thread) => "settled" in thread && thread.settled), | ||
| 283 | true, | ||
| 284 | `${project.title} has no settled thread`, | ||
| 285 | ); | ||
| 286 | assert.equal( | ||
| 287 | projectThreads.some((thread) => !("settled" in thread && thread.settled)), | ||
| 288 | true, | ||
| 289 | `${project.title} has no active thread`, | ||
| 290 | ); | ||
| 291 | } | ||
| 277 | assert.equal( | 292 | assert.equal( |
| 278 | SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")), | 293 | SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")), |
| 279 | true, | 294 | true, |
scripts/mobile-showcase.ts+6-1| ... | @@ -1338,7 +1338,12 @@ async function main(): Promise<void> { | ... | @@ -1338,7 +1338,12 @@ async function main(): Promise<void> { |
| 1338 | 1338 | ||
| 1339 | if (import.meta.main) { | 1339 | if (import.meta.main) { |
| 1340 | void main().catch((error: unknown) => { | 1340 | void main().catch((error: unknown) => { |
| 1341 | NodeProcess.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); | 1341 | // Stack over message: the harness only fails in CI, where the line that |
| 1342 | // threw is the whole diagnosis and there is nobody at a terminal to | ||
| 1343 | // re-run it with more output. | ||
| 1344 | NodeProcess.stderr.write( | ||
| 1345 | `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, | ||
| 1346 | ); | ||
| 1342 | NodeProcess.exit(1); | 1347 | NodeProcess.exit(1); |
| 1343 | }); | 1348 | }); |
| 1344 | } | 1349 | } |