| 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 | 36 | ThreadListRow, |
| 37 | 37 | ThreadListShowMoreRow, |
| 38 | 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 | 40 | import { |
| 41 | 41 | buildThreadListV2Items, |
| 42 | buildThreadListV2ListItems, | |
| 42 | 43 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 43 | 44 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 44 | type ThreadListV2Item, | |
| 45 | type ThreadListV2ListItem, | |
| 45 | 46 | } from "../threads/threadListV2"; |
| 46 | 47 | import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; |
| 47 | 48 | import { |
| ... | ... | @@ -549,50 +550,100 @@ export function HomeScreen(props: HomeScreenProps) { |
| 549 | 550 | // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout |
| 550 | 551 | // range) the boundary string is identical and the chain would die. |
| 551 | 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 | 582 | const renderV2Item = useCallback( |
| 555 | ({ item }: { readonly item: ThreadListV2Item }) => ( | |
| 556 | <ThreadListV2Row | |
| 557 | thread={item.thread} | |
| 558 | variant={item.variant} | |
| 559 | showSettledDivider={item.showSettledDivider} | |
| 560 | project={ | |
| 561 | projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? | |
| 562 | null | |
| 563 | } | |
| 564 | projectTitle={v2ProjectTitleByProjectKey.get( | |
| 565 | scopedProjectKey(item.thread.environmentId, item.thread.projectId), | |
| 566 | )} | |
| 567 | providerDriver={ | |
| 568 | serverConfigs | |
| 569 | .get(item.thread.environmentId) | |
| 570 | ?.providers.find( | |
| 571 | (provider) => | |
| 572 | provider.instanceId === | |
| 573 | (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), | |
| 574 | )?.driver ?? null | |
| 575 | } | |
| 576 | environmentLabel={ | |
| 577 | Object.keys(props.savedConnectionsById).length > 1 | |
| 578 | ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) | |
| 579 | : null | |
| 580 | } | |
| 581 | onSelectThread={props.onSelectThread} | |
| 582 | onDeleteThread={handleDeleteThread} | |
| 583 | onArchiveThread={props.onArchiveThread} | |
| 584 | settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} | |
| 585 | onSettleThread={handleSettleThread} | |
| 586 | onUnsettleThread={handleUnsettleThread} | |
| 587 | onChangeRequestState={handleChangeRequestState} | |
| 588 | projectCwd={ | |
| 589 | projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? | |
| 590 | null | |
| 591 | } | |
| 592 | onSwipeableClose={handleSwipeableClose} | |
| 593 | onSwipeableWillOpen={handleSwipeableWillOpen} | |
| 594 | /> | |
| 595 | ), | |
| 583 | ({ item }: { readonly item: ThreadListV2ListItem }) => { | |
| 584 | if (item.type === "v2-pending") { | |
| 585 | const pendingScopeKey = scopedProjectKey( | |
| 586 | item.pendingTask.message.environmentId, | |
| 587 | item.pendingTask.creation.projectId, | |
| 588 | ); | |
| 589 | return ( | |
| 590 | <ThreadListV2PendingRow | |
| 591 | pendingTask={item.pendingTask} | |
| 592 | project={projectByKey.get(pendingScopeKey) ?? null} | |
| 593 | projectTitle={v2ProjectTitleByProjectKey.get(pendingScopeKey)} | |
| 594 | environmentLabel={ | |
| 595 | Object.keys(props.savedConnectionsById).length > 1 | |
| 596 | ? (props.savedConnectionsById[item.pendingTask.message.environmentId] | |
| 597 | ?.environmentLabel ?? null) | |
| 598 | : null | |
| 599 | } | |
| 600 | showPendingDivider={item.showPendingDivider} | |
| 601 | onSelectPendingTask={props.onSelectPendingTask} | |
| 602 | onDeletePendingTask={props.onDeletePendingTask} | |
| 603 | /> | |
| 604 | ); | |
| 605 | } | |
| 606 | const thread = item.item.thread; | |
| 607 | return ( | |
| 608 | <ThreadListV2Row | |
| 609 | thread={thread} | |
| 610 | variant={item.item.variant} | |
| 611 | showSettledDivider={item.item.showSettledDivider} | |
| 612 | project={ | |
| 613 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null | |
| 614 | } | |
| 615 | projectTitle={v2ProjectTitleByProjectKey.get( | |
| 616 | scopedProjectKey(thread.environmentId, thread.projectId), | |
| 617 | )} | |
| 618 | providerDriver={ | |
| 619 | serverConfigs | |
| 620 | .get(thread.environmentId) | |
| 621 | ?.providers.find( | |
| 622 | (provider) => | |
| 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 | 648 | handleChangeRequestState, |
| 598 | 649 | handleDeleteThread, |
| ... | ... | @@ -603,6 +654,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 603 | 654 | projectByKey, |
| 604 | 655 | projectCwdByKey, |
| 605 | 656 | props.onArchiveThread, |
| 657 | props.onDeletePendingTask, | |
| 658 | props.onSelectPendingTask, | |
| 606 | 659 | props.onSelectThread, |
| 607 | 660 | props.savedConnectionsById, |
| 608 | 661 | serverConfigs, |
| ... | ... | @@ -610,10 +663,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 610 | 663 | v2ProjectTitleByProjectKey, |
| 611 | 664 | ], |
| 612 | 665 | ); |
| 613 | const v2KeyExtractor = useCallback( | |
| 614 | (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, | |
| 615 | [], | |
| 616 | ); | |
| 666 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); | |
| 617 | 667 | |
| 618 | 668 | const extraData = useMemo( |
| 619 | 669 | () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), |
| ... | ... | @@ -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 | 841 | // Project scoping lives in the header filter menu (no inline chip row on |
| 807 | 842 | // mobile — the menu is the one filter surface). |
| 808 | const v2ListHeader = ( | |
| 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 | ); | |
| 843 | const v2ListHeader = listHeader; | |
| 826 | 844 | |
| 827 | 845 | const listEmpty = !hasResults ? ( |
| 828 | 846 | hasSearchQuery ? ( |
| ... | ... | @@ -846,37 +864,33 @@ export function HomeScreen(props: HomeScreenProps) { |
| 846 | 864 | // is empty. Search outranks the scope — "No results" names the actionable |
| 847 | 865 | // fact when a query is active. Snoozed threads outrank the rest: "No |
| 848 | 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 | |
| 850 | // isn't empty in the user's eyes. | |
| 867 | // loss. | |
| 851 | 868 | const v2SnoozedCount = threadListV2Layout.snoozedCount; |
| 852 | const v2ListEmpty = | |
| 853 | v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( | |
| 854 | v2SnoozedCount > 0 ? ( | |
| 855 | // The snoozed threads already passed this search filter: "No | |
| 856 | // results" would claim nothing matched when matches are merely | |
| 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 ? ( | |
| 869 | const v2ListEmpty = hasSearchQuery ? ( | |
| 870 | v2SnoozedCount > 0 ? ( | |
| 871 | // The snoozed threads already passed this search filter: "No | |
| 872 | // results" would claim nothing matched when matches are merely | |
| 873 | // parked. | |
| 868 | 874 | <EmptyState |
| 869 | title={v2SnoozedCount === 1 ? "1 thread snoozed" : `${v2SnoozedCount} threads snoozed`} | |
| 870 | detail="Snoozed threads 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." | |
| 875 | title={v2SnoozedCount === 1 ? "1 matching thread snoozed" : `All matching threads snoozed`} | |
| 876 | detail={`Threads matching "${props.searchQuery}" are snoozed and return when their wake time passes.`} | |
| 876 | 877 | /> |
| 877 | 878 | ) : ( |
| 878 | listEmpty | |
| 879 | ); | |
| 879 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | |
| 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 | 895 | if (threadListV2Enabled) { |
| 882 | 896 | return ( |
| ... | ... | @@ -888,6 +902,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 888 | 902 | keyExtractor={v2KeyExtractor} |
| 889 | 903 | extraData={{ |
| 890 | 904 | projectByKey, |
| 905 | projectCwdByKey, | |
| 906 | projectTitleByProjectKey: v2ProjectTitleByProjectKey, | |
| 891 | 907 | serverConfigs, |
| 892 | 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 | 26 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 27 | 27 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 28 | 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 | 30 | import { useWorkspaceState } from "../../state/workspace"; |
| 31 | 31 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 32 | 32 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | ... | @@ -62,26 +62,21 @@ import { |
| 62 | 62 | ThreadListRow, |
| 63 | 63 | ThreadListShowMoreRow, |
| 64 | 64 | } from "./thread-list-items"; |
| 65 | import { ThreadListV2Row } from "./thread-list-v2-items"; | |
| 65 | import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; | |
| 66 | 66 | import { |
| 67 | 67 | buildThreadListV2Items, |
| 68 | buildThreadListV2ListItems, | |
| 68 | 69 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 69 | 70 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 70 | type ThreadListV2Item, | |
| 71 | type ThreadListV2ListItem, | |
| 71 | 72 | } from "./threadListV2"; |
| 72 | 73 | |
| 73 | 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 | 76 | "Show more" pager. */ |
| 76 | 77 | type SidebarListItem = |
| 77 | 78 | | HomeListItem |
| 78 | | { | |
| 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 } | |
| 79 | | ThreadListV2ListItem | |
| 85 | 80 | | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; |
| 86 | 81 | |
| 87 | 82 | /** |
| ... | ... | @@ -473,11 +468,11 @@ function ThreadNavigationSidebarPane( |
| 473 | 468 | }, [nextSnoozeWakeAt, snoozeWakeTick]); |
| 474 | 469 | const listItems = useMemo<readonly SidebarListItem[]>(() => { |
| 475 | 470 | if (!threadListV2Enabled) return listLayout.items; |
| 476 | // Queued offline tasks render above the thread rows (mirrors the | |
| 477 | // compact Home v2 list): they are not thread shells, so the v2 item | |
| 478 | // builder never sees them, but they must stay visible and deletable | |
| 479 | // while their environment is offline. Same environment scope and | |
| 480 | // search filter as the list. | |
| 471 | // Queued offline tasks are not thread shells, so the v2 item builder | |
| 472 | // never sees them; the shared splice puts them below the active block | |
| 473 | // (mirrors the compact Home v2 list) where they stay visible and | |
| 474 | // deletable while their environment is offline. Same environment scope | |
| 475 | // and search filter as the list. | |
| 481 | 476 | const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); |
| 482 | 477 | const v2PendingTasks = pendingTasks.filter( |
| 483 | 478 | (pendingTask) => |
| ... | ... | @@ -490,19 +485,10 @@ function ThreadNavigationSidebarPane( |
| 490 | 485 | (v2SearchQuery.length === 0 || |
| 491 | 486 | pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), |
| 492 | 487 | ); |
| 493 | const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ | |
| 494 | type: "v2-pending-task" as const, | |
| 495 | key: `v2-pending:${pendingTask.message.messageId}`, | |
| 496 | pendingTask, | |
| 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 | } | |
| 488 | const items: SidebarListItem[] = buildThreadListV2ListItems({ | |
| 489 | items: threadListV2Layout.items, | |
| 490 | pendingTasks: v2PendingTasks, | |
| 491 | }); | |
| 506 | 492 | if (threadListV2Layout.hiddenSettledCount > 0) { |
| 507 | 493 | items.push({ |
| 508 | 494 | type: "v2-show-more", |
| ... | ... | @@ -690,13 +676,26 @@ function ThreadNavigationSidebarPane( |
| 690 | 676 | onScroll: handleScroll, |
| 691 | 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 | 682 | const listExtraData = useMemo( |
| 694 | 683 | () => ({ |
| 695 | 684 | selectedThreadKey: props.selectedThreadKey ?? "", |
| 685 | projectByKey, | |
| 686 | projectCwdByKey, | |
| 687 | projectTitleByProjectKey, | |
| 696 | 688 | savedConnectionsById, |
| 697 | 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 | 700 | const sidebarItemsAreEqual = useCallback( |
| 702 | 701 | (previous: SidebarListItem, item: SidebarListItem): boolean => { |
| ... | ... | @@ -711,16 +710,19 @@ function ThreadNavigationSidebarPane( |
| 711 | 710 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| 712 | 711 | return previous.hiddenCount === item.hiddenCount; |
| 713 | 712 | } |
| 714 | if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { | |
| 715 | return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; | |
| 713 | if (previous.type === "v2-pending" && item.type === "v2-pending") { | |
| 714 | return ( | |
| 715 | previous.pendingTask === item.pendingTask && | |
| 716 | previous.showPendingDivider === item.showPendingDivider | |
| 717 | ); | |
| 716 | 718 | } |
| 717 | 719 | if ( |
| 718 | 720 | previous.type === "v2-thread" || |
| 719 | 721 | previous.type === "v2-show-more" || |
| 720 | previous.type === "v2-pending-task" || | |
| 722 | previous.type === "v2-pending" || | |
| 721 | 723 | item.type === "v2-thread" || |
| 722 | 724 | item.type === "v2-show-more" || |
| 723 | item.type === "v2-pending-task" | |
| 725 | item.type === "v2-pending" | |
| 724 | 726 | ) { |
| 725 | 727 | return false; |
| 726 | 728 | } |
| ... | ... | @@ -748,20 +750,29 @@ function ThreadNavigationSidebarPane( |
| 748 | 750 | const renderListItem = useCallback( |
| 749 | 751 | ({ item }: { readonly item: SidebarListItem }) => { |
| 750 | 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 | 758 | return ( |
| 753 | <PendingTaskListRow | |
| 754 | variant="sidebar" | |
| 759 | <ThreadListV2PendingRow | |
| 755 | 760 | pendingTask={item.pendingTask} |
| 761 | project={projectByKey.get(pendingScopeKey) ?? null} | |
| 762 | projectTitle={projectTitleByProjectKey.get(pendingScopeKey)} | |
| 756 | 763 | environmentLabel={ |
| 757 | savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? | |
| 758 | null | |
| 764 | Object.keys(savedConnectionsById).length > 1 | |
| 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 | 771 | onSelectPendingTask={openPendingTask} |
| 762 | 772 | onDeletePendingTask={confirmDeletePendingTask} |
| 763 | 773 | /> |
| 764 | 774 | ); |
| 775 | } | |
| 765 | 776 | case "v2-thread": { |
| 766 | 777 | const thread = item.item.thread; |
| 767 | 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 | 14 | import { cn } from "../../lib/cn"; |
| 15 | 15 | import { relativeTime } from "../../lib/time"; |
| 16 | 16 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 17 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 17 | 18 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 18 | 19 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 19 | 20 | import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; |
| ... | ... | @@ -68,7 +69,9 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ |
| 68 | 69 | /** Rounded-row radius shared with the v1 sidebar rows. */ |
| 69 | 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 | 75 | readonly pane?: "screen" | "sidebar"; |
| 73 | 76 | }) { |
| 74 | 77 | const borderColor = useThemeColor("--color-border"); |
| ... | ... | @@ -79,12 +82,127 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid |
| 79 | 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 | 86 | <View className="h-px flex-1" style={{ backgroundColor: borderColor }} /> |
| 84 | 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 | 206 | export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 89 | 207 | readonly thread: EnvironmentThreadShell; |
| 90 | 208 | readonly variant: "card" | "slim"; |
| ... | ... | @@ -423,7 +541,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 423 | 541 | |
| 424 | 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 | 547 | <ThreadSwipeable |
| 428 | 548 | backgroundColor={sidebarPane ? drawerColor : screenColor} |
| 429 | 549 | compactActions={variant === "slim"} |
apps/mobile/src/features/threads/threadListV2.test.ts+96-1| ... | ... | @@ -1,9 +1,19 @@ |
| 1 | 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 | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 12 | |
| 13 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 5 | 14 | import { |
| 6 | 15 | buildThreadListV2Items, |
| 16 | buildThreadListV2ListItems, | |
| 7 | 17 | resolveThreadListV2Enabled, |
| 8 | 18 | resolveThreadListV2Status, |
| 9 | 19 | sortThreadsForListV2, |
| ... | ... | @@ -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 | 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 3 | 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 4 | 4 | |
| 5 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | |
| 6 | ||
| 5 | 7 | /** |
| 6 | 8 | * Thread List v2 model, ported from the web sidebar v2 |
| 7 | 9 | * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). |
| ... | ... | @@ -113,6 +115,60 @@ export interface ThreadListV2Layout { |
| 113 | 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 | 173 | * Partitions visible threads into the active card block (creation order) and |
| 118 | 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 | 201 | response: |
| 202 | 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 | 232 | projectId: "linux", |
| 207 | title: "Let the scheduler breathe", | |
| 208 | branch: "perf/scheduler-breathe", | |
| 209 | minutesAgo: 76, | |
| 210 | request: | |
| 211 | "Find a calmer balancing strategy for bursty mixed workloads without hurting tail latency.", | |
| 233 | title: "Make the OOM killer explain itself", | |
| 234 | branch: "feat/quieter-oom", | |
| 235 | minutesAgo: 2 * 24 * 60, | |
| 236 | settled: true, | |
| 237 | request: "Make out-of-memory kills legible without adding a single allocation to the hot path.", | |
| 212 | 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 | 241 | ] as const; |
| 216 | 242 | |
| ... | ... | @@ -300,6 +326,7 @@ function insertThread( |
| 300 | 326 | readonly branch: string; |
| 301 | 327 | readonly minutesAgo: number; |
| 302 | 328 | readonly state?: "working" | "approval" | "plan"; |
| 329 | readonly settled?: boolean; | |
| 303 | 330 | readonly workspaceRoot: string; |
| 304 | 331 | }, |
| 305 | 332 | ): void { |
| ... | ... | @@ -312,8 +339,8 @@ function insertThread( |
| 312 | 339 | thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, |
| 313 | 340 | branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, |
| 314 | 341 | pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, |
| 315 | archived_at, deleted_at | |
| 316 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)`, | |
| 342 | archived_at, deleted_at, settled_override, settled_at | |
| 343 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL, ?, ?)`, | |
| 317 | 344 | ) |
| 318 | 345 | .run( |
| 319 | 346 | input.id, |
| ... | ... | @@ -330,6 +357,8 @@ function insertThread( |
| 330 | 357 | input.state === "plan" ? 1 : 0, |
| 331 | 358 | minutesBefore(now, input.minutesAgo + 120), |
| 332 | 359 | updatedAt, |
| 360 | input.settled ? "settled" : null, | |
| 361 | input.settled ? updatedAt : null, | |
| 333 | 362 | ); |
| 334 | 363 | database |
| 335 | 364 | .prepare( |
| ... | ... | @@ -365,7 +394,11 @@ function seedDatabase( |
| 365 | 394 | threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, |
| 366 | 395 | now: number, |
| 367 | 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 | 402 | try { |
| 370 | 403 | database.exec("BEGIN IMMEDIATE"); |
| 371 | 404 | for (const table of [ |
| ... | ... | @@ -506,7 +539,14 @@ function seedDatabase( |
| 506 | 539 | } |
| 507 | 540 | database.exec("COMMIT"); |
| 508 | 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 | 550 | throw error; |
| 511 | 551 | } finally { |
| 512 | 552 | database.close(); |
scripts/mobile-showcase.test.ts+16-1| ... | ... | @@ -272,8 +272,23 @@ it("seeds a playful multi-environment project spectrum", () => { |
| 272 | 272 | SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), |
| 273 | 273 | ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], |
| 274 | 274 | ); |
| 275 | assert.equal(SHOWCASE_THREADS.length, 6); | |
| 275 | assert.equal(SHOWCASE_THREADS.length, 8); | |
| 276 | 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 | 292 | assert.equal( |
| 278 | 293 | SHOWCASE_PROJECTS.every((project) => project.favicon.includes("<svg")), |
| 279 | 294 | true, |
scripts/mobile-showcase.ts+6-1| ... | ... | @@ -1338,7 +1338,12 @@ async function main(): Promise<void> { |
| 1338 | 1338 | |
| 1339 | 1339 | if (import.meta.main) { |
| 1340 | 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 | 1347 | NodeProcess.exit(1); |
| 1343 | 1348 | }); |
| 1344 | 1349 | } |