authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-28 14:09:34+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 14:09:34+02:00
logf1a68ac9b05c93292e677f700ae3ce7f7ac7f064
treea302d99d246bcfee8297d017f817afd32ae826a7
parent55dd01612efc51e19de479da5a0e348cbe2521e3
signature Signed by PGP key B5690EEEBB952194

Fix Android showcase capture and rebuild v2 queued rows (#4730)

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";
39import { ThreadListV2Row } from "../threads/thread-list-v2-items";39import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items";
40import {40import {
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";
46import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";47import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
47import {48import {
...@@ -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 setTimeout550 // 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 );
553581
554 const renderV2Item = useCallback(582 const renderV2Item = useCallback(
555 ({ item }: { readonly item: ThreadListV2Item }) => (583 ({ item }: { readonly item: ThreadListV2ListItem }) => {
556 <ThreadListV2Row584 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 null590 <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 serverConfigs596 ? (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 ?? null602 onDeletePendingTask={props.onDeletePendingTask}
575 }603 />
576 environmentLabel={604 );
577 Object.keys(props.savedConnectionsById).length > 1605 }
578 ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null)606 const thread = item.item.thread;
579 : null607 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 null618 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 );
617667
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 );
790840
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 on841 // 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 );
826844
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 actionable864 // is empty. Search outranks the scope — "No results" names the actionable
847 // fact when a query is active. Snoozed threads outrank the rest: "No865 // 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 data866 // 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 them867 // 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: "No872 // results" would claim nothing matched when matches are merely
856 // results" would claim nothing matched when matches are merely873 // 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 <EmptyState874 <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 listEmpty879 <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 );
880894
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";
26import { useProjects, useThreadShells } from "../../state/entities";26import { useProjects, useThreadShells } from "../../state/entities";
27import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";27import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
28import { environmentServerConfigsAtom } from "../../state/server";28import { environmentServerConfigsAtom } from "../../state/server";
29import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks";29import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
30import { useWorkspaceState } from "../../state/workspace";30import { useWorkspaceState } from "../../state/workspace";
31import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";31import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
32import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";32import { 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";
65import { ThreadListV2Row } from "./thread-list-v2-items";65import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items";
66import {66import {
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";
7273
73/** The sidebar list serves both lists: v1 grouped items or, when the Thread74/** 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 settled75 List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled
75 "Show more" pager. */76 "Show more" pager. */
76type SidebarListItem =77type SidebarListItem =
77 | HomeListItem78 | 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 };
8681
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 the471 // 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 item472 // never sees them; the shared splice puts them below the active block
478 // builder never sees them, but they must stay visible and deletable473 // (mirrors the compact Home v2 list) where they stay visible and
479 // while their environment is offline. Same environment scope and474 // 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 <PendingTaskListRow759 <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 null765 ? (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";
14import { cn } from "../../lib/cn";14import { cn } from "../../lib/cn";
15import { relativeTime } from "../../lib/time";15import { relativeTime } from "../../lib/time";
16import { useThemeColor } from "../../lib/useThemeColor";16import { useThemeColor } from "../../lib/useThemeColor";
17import type { PendingNewTask } from "../../state/use-pending-new-tasks";
17import { useThreadPr } from "../../state/use-thread-pr";18import { useThreadPr } from "../../state/use-thread-pr";
18import { ThreadSwipeable } from "../home/thread-swipe-actions";19import { ThreadSwipeable } from "../home/thread-swipe-actions";
19import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";20import { 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. */
69const SIDEBAR_V2_ROW_RADIUS = 12;70const SIDEBAR_V2_ROW_RADIUS = 12;
7071
71export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: {72/** Section label + rule: the only structure in an otherwise flat list. */
73export 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});
8790
91const 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 */
102export 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
88export const ThreadListV2Row = memo(function ThreadListV2Row(props: {206export 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: {
423541
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 <ThreadSwipeable547 <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 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";2import {
3 CommandId,
4 EnvironmentId,
5 MessageId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9 TurnId,
10} from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";11import { describe, expect, it } from "vite-plus/test";
412
13import type { PendingNewTask } from "../../state/use-pending-new-tasks";
5import {14import {
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
354function 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
381describe("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
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
44
5import type { PendingNewTask } from "../../state/use-pending-new-tasks";
6
5/**7/**
6 * Thread List v2 model, ported from the web sidebar v28 * 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}
115117
118export interface ThreadListV2ThreadListItem {
119 readonly type: "v2-thread";
120 readonly key: string;
121 readonly item: ThreadListV2Item;
122}
123
124export 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
132export 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 */
141export 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) and173 * 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;
216242
...@@ -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_at342 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 database363 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> {
13381338
1339if (import.meta.main) {1339if (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}