| author | |
| committer | |
| log | 4ac094fef689b71f475e41a9a2fce167804a5e4e |
| tree | 06828c512402ccdef2bf731783dc42b137d4f660 |
| parent | a7e43b2281879606785707411c1517cd2aca4c08 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 1446 insertions(+), 153 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+7| ... | @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; | ... | @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; |
| 5 | 5 | ||
| 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { useProjects, useThreadShells } from "../../state/entities"; | 7 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 8 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | ||
| 8 | import { useWorkspaceState } from "../../state/workspace"; | 9 | import { useWorkspaceState } from "../../state/workspace"; |
| 9 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 10 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 10 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| ... | @@ -13,6 +14,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | ... | @@ -13,6 +14,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 13 | import { HomeScreen } from "./HomeScreen"; | 14 | import { HomeScreen } from "./HomeScreen"; |
| 14 | import { HomeHeader } from "./HomeHeader"; | 15 | import { HomeHeader } from "./HomeHeader"; |
| 15 | import { useHomeListOptions } from "./home-list-options"; | 16 | import { useHomeListOptions } from "./home-list-options"; |
| 17 | import { usePendingTaskListActions } from "./usePendingTaskListActions"; | ||
| 16 | import { useThreadListActions } from "./useThreadListActions"; | 18 | import { useThreadListActions } from "./useThreadListActions"; |
| 17 | 19 | ||
| 18 | /* ─── Route screen ───────────────────────────────────────────────────── */ | 20 | /* ─── Route screen ───────────────────────────────────────────────────── */ |
| ... | @@ -26,6 +28,8 @@ export function HomeRouteScreen() { | ... | @@ -26,6 +28,8 @@ export function HomeRouteScreen() { |
| 26 | const navigation = useNavigation(); | 28 | const navigation = useNavigation(); |
| 27 | const [searchQuery, setSearchQuery] = useState(""); | 29 | const [searchQuery, setSearchQuery] = useState(""); |
| 28 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | 30 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); |
| 31 | const pendingTasks = usePendingNewTasks(); | ||
| 32 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | ||
| 29 | const environments = useMemo( | 33 | const environments = useMemo( |
| 30 | () => | 34 | () => |
| 31 | Arr.sort( | 35 | Arr.sort( |
| ... | @@ -116,8 +120,11 @@ export function HomeRouteScreen() { | ... | @@ -116,8 +120,11 @@ export function HomeRouteScreen() { |
| 116 | threadId: thread.id, | 120 | threadId: thread.id, |
| 117 | }); | 121 | }); |
| 118 | }} | 122 | }} |
| 123 | onSelectPendingTask={openPendingTask} | ||
| 124 | onDeletePendingTask={confirmDeletePendingTask} | ||
| 119 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 125 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 120 | onThreadSortOrderChange={setThreadSortOrder} | 126 | onThreadSortOrderChange={setThreadSortOrder} |
| 127 | pendingTasks={pendingTasks} | ||
| 121 | projectGroupingMode={listOptions.projectGroupingMode} | 128 | projectGroupingMode={listOptions.projectGroupingMode} |
| 122 | projects={projects} | 129 | projects={projects} |
| 123 | projectSortOrder={listOptions.projectSortOrder} | 130 | projectSortOrder={listOptions.projectSortOrder} |
apps/mobile/src/features/home/HomeScreen.tsx+26-2| ... | @@ -22,7 +22,9 @@ import { EmptyState } from "../../components/EmptyState"; | ... | @@ -22,7 +22,9 @@ import { EmptyState } from "../../components/EmptyState"; |
| 22 | import type { WorkspaceState } from "../../state/workspaceModel"; | 22 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 23 | import type { SavedRemoteConnection } from "../../lib/connection"; | 23 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 24 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 24 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 25 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 25 | import { | 26 | import { |
| 27 | PendingTaskListRow, | ||
| 26 | ThreadListGroupHeader, | 28 | ThreadListGroupHeader, |
| 27 | ThreadListRow, | 29 | ThreadListRow, |
| 28 | ThreadListShowMoreRow, | 30 | ThreadListShowMoreRow, |
| ... | @@ -47,6 +49,7 @@ import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-stat | ... | @@ -47,6 +49,7 @@ import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-stat |
| 47 | interface HomeScreenProps { | 49 | interface HomeScreenProps { |
| 48 | readonly projects: ReadonlyArray<EnvironmentProject>; | 50 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 49 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 51 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 52 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | ||
| 50 | readonly catalogState: WorkspaceState; | 53 | readonly catalogState: WorkspaceState; |
| 51 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; | 54 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; |
| 52 | readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>; | 55 | readonly environments: ReadonlyArray<HomeListFilterMenuEnvironment>; |
| ... | @@ -67,6 +70,8 @@ interface HomeScreenProps { | ... | @@ -67,6 +70,8 @@ interface HomeScreenProps { |
| 67 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 70 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 68 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; | 71 | readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; |
| 69 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 72 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 73 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | ||
| 74 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | ||
| 70 | } | 75 | } |
| 71 | 76 | ||
| 72 | /* ─── Layout constants ───────────────────────────────────────────────── */ | 77 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| ... | @@ -189,6 +194,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -189,6 +194,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 189 | buildHomeThreadGroups({ | 194 | buildHomeThreadGroups({ |
| 190 | projects: props.projects, | 195 | projects: props.projects, |
| 191 | threads: props.threads, | 196 | threads: props.threads, |
| 197 | pendingTasks: props.pendingTasks, | ||
| 192 | environmentId: props.selectedEnvironmentId, | 198 | environmentId: props.selectedEnvironmentId, |
| 193 | searchQuery: props.searchQuery, | 199 | searchQuery: props.searchQuery, |
| 194 | projectSortOrder: props.projectSortOrder, | 200 | projectSortOrder: props.projectSortOrder, |
| ... | @@ -196,6 +202,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -196,6 +202,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 196 | projectGroupingMode: props.projectGroupingMode, | 202 | projectGroupingMode: props.projectGroupingMode, |
| 197 | }), | 203 | }), |
| 198 | [ | 204 | [ |
| 205 | props.pendingTasks, | ||
| 199 | props.projectGroupingMode, | 206 | props.projectGroupingMode, |
| 200 | props.projects, | 207 | props.projects, |
| 201 | props.projectSortOrder, | 208 | props.projectSortOrder, |
| ... | @@ -242,10 +249,24 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -242,10 +249,24 @@ export function HomeScreen(props: HomeScreenProps) { |
| 242 | groupKey={item.group.key} | 249 | groupKey={item.group.key} |
| 243 | onGroupAction={updateGroupDisplay} | 250 | onGroupAction={updateGroupDisplay} |
| 244 | project={item.group.representative} | 251 | project={item.group.representative} |
| 245 | threadCount={item.group.threads.length} | 252 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 246 | title={item.group.title} | 253 | title={item.group.title} |
| 247 | /> | 254 | /> |
| 248 | ); | 255 | ); |
| 256 | case "pending-task": | ||
| 257 | return ( | ||
| 258 | <PendingTaskListRow | ||
| 259 | variant="compact" | ||
| 260 | pendingTask={item.pendingTask} | ||
| 261 | environmentLabel={ | ||
| 262 | props.savedConnectionsById[item.pendingTask.message.environmentId] | ||
| 263 | ?.environmentLabel ?? null | ||
| 264 | } | ||
| 265 | isLast={item.isLast} | ||
| 266 | onSelectPendingTask={props.onSelectPendingTask} | ||
| 267 | onDeletePendingTask={props.onDeletePendingTask} | ||
| 268 | /> | ||
| 269 | ); | ||
| 249 | case "thread": { | 270 | case "thread": { |
| 250 | const thread = item.thread; | 271 | const thread = item.thread; |
| 251 | return ( | 272 | return ( |
| ... | @@ -285,7 +306,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -285,7 +306,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 285 | handleSwipeableWillOpen, | 306 | handleSwipeableWillOpen, |
| 286 | projectCwdByKey, | 307 | projectCwdByKey, |
| 287 | props.onArchiveThread, | 308 | props.onArchiveThread, |
| 309 | props.onDeletePendingTask, | ||
| 288 | props.onDeleteThread, | 310 | props.onDeleteThread, |
| 311 | props.onSelectPendingTask, | ||
| 289 | props.onSelectThread, | 312 | props.onSelectThread, |
| 290 | props.savedConnectionsById, | 313 | props.savedConnectionsById, |
| 291 | updateGroupDisplay, | 314 | updateGroupDisplay, |
| ... | @@ -295,7 +318,8 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -295,7 +318,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 295 | const keyExtractor = useCallback((item: HomeListItem) => item.key, []); | 318 | const keyExtractor = useCallback((item: HomeListItem) => item.key, []); |
| 296 | 319 | ||
| 297 | /* Empty states */ | 320 | /* Empty states */ |
| 298 | const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null); | 321 | const hasAnyThreads = |
| 322 | props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; | ||
| 299 | const hasResults = projectGroups.length > 0; | 323 | const hasResults = projectGroups.length > 0; |
| 300 | const selectedEnvironmentLabel = | 324 | const selectedEnvironmentLabel = |
| 301 | props.selectedEnvironmentId === null | 325 | props.selectedEnvironmentId === null |
apps/mobile/src/features/home/homeListItems.test.ts+1| ... | @@ -62,6 +62,7 @@ function makeGroup(key: string, threadCount: number): HomeThreadGroup { | ... | @@ -62,6 +62,7 @@ function makeGroup(key: string, threadCount: number): HomeThreadGroup { |
| 62 | title: key, | 62 | title: key, |
| 63 | representative: project, | 63 | representative: project, |
| 64 | projects: [project], | 64 | projects: [project], |
| 65 | pendingTasks: [], | ||
| 65 | threads: Array.from({ length: threadCount }, (_, index) => | 66 | threads: Array.from({ length: threadCount }, (_, index) => |
| 66 | makeThread(`${key}-thread-${index}`, project.id), | 67 | makeThread(`${key}-thread-${index}`, project.id), |
| 67 | ), | 68 | ), |
apps/mobile/src/features/home/homeListItems.ts+32-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | 2 | ||
| 3 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 3 | import type { HomeThreadGroup } from "./homeThreadList"; | 4 | import type { HomeThreadGroup } from "./homeThreadList"; |
| 4 | 5 | ||
| 5 | /** Threads shown per project before the "Show more" affordance appears. */ | 6 | /** Threads shown per project before the "Show more" affordance appears. */ |
| ... | @@ -33,6 +34,13 @@ export interface HomeThreadListItem { | ... | @@ -33,6 +34,13 @@ export interface HomeThreadListItem { |
| 33 | readonly isLast: boolean; | 34 | readonly isLast: boolean; |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 37 | export interface HomePendingTaskListItem { | ||
| 38 | readonly type: "pending-task"; | ||
| 39 | readonly key: string; | ||
| 40 | readonly pendingTask: PendingNewTask; | ||
| 41 | readonly isLast: boolean; | ||
| 42 | } | ||
| 43 | |||
| 36 | export interface HomeShowMoreListItem { | 44 | export interface HomeShowMoreListItem { |
| 37 | readonly type: "show-more"; | 45 | readonly type: "show-more"; |
| 38 | readonly key: string; | 46 | readonly key: string; |
| ... | @@ -43,7 +51,11 @@ export interface HomeShowMoreListItem { | ... | @@ -43,7 +51,11 @@ export interface HomeShowMoreListItem { |
| 43 | readonly canShowLess: boolean; | 51 | readonly canShowLess: boolean; |
| 44 | } | 52 | } |
| 45 | 53 | ||
| 46 | export type HomeListItem = HomeHeaderListItem | HomeThreadListItem | HomeShowMoreListItem; | 54 | export type HomeListItem = |
| 55 | | HomeHeaderListItem | ||
| 56 | | HomePendingTaskListItem | ||
| 57 | | HomeThreadListItem | ||
| 58 | | HomeShowMoreListItem; | ||
| 47 | 59 | ||
| 48 | export interface HomeListLayout { | 60 | export interface HomeListLayout { |
| 49 | readonly items: ReadonlyArray<HomeListItem>; | 61 | readonly items: ReadonlyArray<HomeListItem>; |
| ... | @@ -82,6 +94,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem | ... | @@ -82,6 +94,12 @@ export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem |
| 82 | previous.collapsed === item.collapsed && | 94 | previous.collapsed === item.collapsed && |
| 83 | previous.isFirst === item.isFirst | 95 | previous.isFirst === item.isFirst |
| 84 | ); | 96 | ); |
| 97 | case "pending-task": | ||
| 98 | return ( | ||
| 99 | previous.type === "pending-task" && | ||
| 100 | previous.pendingTask === item.pendingTask && | ||
| 101 | previous.isLast === item.isLast | ||
| 102 | ); | ||
| 85 | case "thread": | 103 | case "thread": |
| 86 | return ( | 104 | return ( |
| 87 | previous.type === "thread" && | 105 | previous.type === "thread" && |
| ... | @@ -134,6 +152,19 @@ export function buildHomeListLayout(input: { | ... | @@ -134,6 +152,19 @@ export function buildHomeListLayout(input: { |
| 134 | const hiddenCount = totalCount - visibleCount; | 152 | const hiddenCount = totalCount - visibleCount; |
| 135 | const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS; | 153 | const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS; |
| 136 | 154 | ||
| 155 | // Pending (unsent) tasks lead the group and are never paginated away. | ||
| 156 | for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { | ||
| 157 | items.push({ | ||
| 158 | type: "pending-task", | ||
| 159 | key: `pending-task:${pendingTask.message.messageId}`, | ||
| 160 | pendingTask, | ||
| 161 | isLast: | ||
| 162 | pendingIndex === group.pendingTasks.length - 1 && | ||
| 163 | visibleThreads.length === 0 && | ||
| 164 | !hasShowMoreRow, | ||
| 165 | }); | ||
| 166 | } | ||
| 167 | |||
| 137 | for (const [threadIndex, thread] of visibleThreads.entries()) { | 168 | for (const [threadIndex, thread] of visibleThreads.entries()) { |
| 138 | items.push({ | 169 | items.push({ |
| 139 | type: "thread", | 170 | type: "thread", |
apps/mobile/src/features/home/homeThreadList.ts+57-4| ... | @@ -17,6 +17,7 @@ import * as Arr from "effect/Array"; | ... | @@ -17,6 +17,7 @@ import * as Arr from "effect/Array"; |
| 17 | import * as Order from "effect/Order"; | 17 | import * as Order from "effect/Order"; |
| 18 | 18 | ||
| 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 20 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 20 | 21 | ||
| 21 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; | 22 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| 22 | 23 | ||
| ... | @@ -25,25 +26,32 @@ export interface HomeThreadGroup { | ... | @@ -25,25 +26,32 @@ export interface HomeThreadGroup { |
| 25 | readonly title: string; | 26 | readonly title: string; |
| 26 | readonly representative: EnvironmentProject; | 27 | readonly representative: EnvironmentProject; |
| 27 | readonly projects: ReadonlyArray<EnvironmentProject>; | 28 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 29 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | ||
| 28 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 30 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 29 | } | 31 | } |
| 30 | 32 | ||
| 31 | interface MutableHomeThreadGroup { | 33 | interface MutableHomeThreadGroup { |
| 32 | readonly key: string; | 34 | readonly key: string; |
| 33 | readonly projects: EnvironmentProject[]; | 35 | readonly projects: EnvironmentProject[]; |
| 36 | readonly pendingTasks: PendingNewTask[]; | ||
| 34 | readonly threads: EnvironmentThreadShell[]; | 37 | readonly threads: EnvironmentThreadShell[]; |
| 35 | } | 38 | } |
| 36 | 39 | ||
| 37 | function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { | 40 | function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { |
| 38 | return group.threads.reduce( | 41 | const latestThread = group.threads.reduce( |
| 39 | (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), | 42 | (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), |
| 40 | Number.NEGATIVE_INFINITY, | 43 | Number.NEGATIVE_INFINITY, |
| 41 | ); | 44 | ); |
| 45 | return group.pendingTasks.reduce((latest, pendingTask) => { | ||
| 46 | const timestamp = Date.parse(pendingTask.message.createdAt); | ||
| 47 | return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); | ||
| 48 | }, latestThread); | ||
| 42 | } | 49 | } |
| 43 | 50 | ||
| 44 | export function buildHomeThreadGroups(input: { | 51 | export function buildHomeThreadGroups(input: { |
| 45 | readonly projects: ReadonlyArray<EnvironmentProject>; | 52 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 46 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 53 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 54 | readonly pendingTasks?: ReadonlyArray<PendingNewTask>; | ||
| 47 | readonly environmentId: EnvironmentId | null; | 55 | readonly environmentId: EnvironmentId | null; |
| 48 | readonly searchQuery: string; | 56 | readonly searchQuery: string; |
| 49 | readonly projectSortOrder: HomeProjectSortOrder; | 57 | readonly projectSortOrder: HomeProjectSortOrder; |
| ... | @@ -68,10 +76,49 @@ export function buildHomeThreadGroups(input: { | ... | @@ -68,10 +76,49 @@ export function buildHomeThreadGroups(input: { |
| 68 | if (existing) { | 76 | if (existing) { |
| 69 | existing.projects.push(project); | 77 | existing.projects.push(project); |
| 70 | } else { | 78 | } else { |
| 71 | groups.set(groupKey, { key: groupKey, projects: [project], threads: [] }); | 79 | groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] }); |
| 72 | } | 80 | } |
| 73 | } | 81 | } |
| 74 | 82 | ||
| 83 | for (const pendingTask of input.pendingTasks ?? []) { | ||
| 84 | if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { | ||
| 85 | continue; | ||
| 86 | } | ||
| 87 | |||
| 88 | const physicalKey = scopedProjectKey( | ||
| 89 | pendingTask.message.environmentId, | ||
| 90 | pendingTask.creation.projectId, | ||
| 91 | ); | ||
| 92 | let groupKey = groupKeyByProjectKey.get(physicalKey); | ||
| 93 | if (!groupKey) { | ||
| 94 | // The project shell is not loaded (environment offline / project gone). | ||
| 95 | // A queued task must stay visible and deletable regardless, so build a | ||
| 96 | // standalone group from the metadata snapshotted at enqueue time. | ||
| 97 | groupKey = `pending-project:${physicalKey}`; | ||
| 98 | groupKeyByProjectKey.set(physicalKey, groupKey); | ||
| 99 | groups.set(groupKey, { | ||
| 100 | key: groupKey, | ||
| 101 | projects: [ | ||
| 102 | { | ||
| 103 | environmentId: pendingTask.message.environmentId, | ||
| 104 | id: pendingTask.creation.projectId, | ||
| 105 | title: pendingTask.creation.projectTitle ?? "Unknown project", | ||
| 106 | workspaceRoot: | ||
| 107 | pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), | ||
| 108 | repositoryIdentity: null, | ||
| 109 | defaultModelSelection: null, | ||
| 110 | scripts: [], | ||
| 111 | createdAt: pendingTask.message.createdAt, | ||
| 112 | updatedAt: pendingTask.message.createdAt, | ||
| 113 | }, | ||
| 114 | ], | ||
| 115 | pendingTasks: [], | ||
| 116 | threads: [], | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | groups.get(groupKey)?.pendingTasks.push(pendingTask); | ||
| 120 | } | ||
| 121 | |||
| 75 | for (const thread of input.threads) { | 122 | for (const thread of input.threads) { |
| 76 | if (thread.archivedAt !== null) { | 123 | if (thread.archivedAt !== null) { |
| 77 | continue; | 124 | continue; |
| ... | @@ -93,7 +140,7 @@ export function buildHomeThreadGroups(input: { | ... | @@ -93,7 +140,7 @@ export function buildHomeThreadGroups(input: { |
| 93 | 140 | ||
| 94 | for (const group of groups.values()) { | 141 | for (const group of groups.values()) { |
| 95 | const representative = group.projects[0]; | 142 | const representative = group.projects[0]; |
| 96 | if (!representative || group.threads.length === 0) { | 143 | if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { |
| 97 | continue; | 144 | continue; |
| 98 | } | 145 | } |
| 99 | 146 | ||
| ... | @@ -108,8 +155,13 @@ export function buildHomeThreadGroups(input: { | ... | @@ -108,8 +155,13 @@ export function buildHomeThreadGroups(input: { |
| 108 | const matchingThreads = groupMatches | 155 | const matchingThreads = groupMatches |
| 109 | ? group.threads | 156 | ? group.threads |
| 110 | : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); | 157 | : group.threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); |
| 158 | const matchingPendingTasks = groupMatches | ||
| 159 | ? group.pendingTasks | ||
| 160 | : group.pendingTasks.filter((pendingTask) => | ||
| 161 | pendingTask.title.toLocaleLowerCase().includes(query), | ||
| 162 | ); | ||
| 111 | 163 | ||
| 112 | if (matchingThreads.length === 0) { | 164 | if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { |
| 113 | continue; | 165 | continue; |
| 114 | } | 166 | } |
| 115 | 167 | ||
| ... | @@ -118,6 +170,7 @@ export function buildHomeThreadGroups(input: { | ... | @@ -118,6 +170,7 @@ export function buildHomeThreadGroups(input: { |
| 118 | title, | 170 | title, |
| 119 | representative, | 171 | representative, |
| 120 | projects: group.projects, | 172 | projects: group.projects, |
| 173 | pendingTasks: matchingPendingTasks, | ||
| 121 | threads: sortThreads(matchingThreads, input.threadSortOrder), | 174 | threads: sortThreads(matchingThreads, input.threadSortOrder), |
| 122 | }); | 175 | }); |
| 123 | } | 176 | } |
apps/mobile/src/features/home/usePendingTaskListActions.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import { useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | import { Alert } from "react-native"; | ||
| 4 | |||
| 5 | import { removeThreadOutboxMessage } from "../../state/thread-outbox"; | ||
| 6 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ||
| 7 | import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; | ||
| 8 | |||
| 9 | export function usePendingTaskListActions(): { | ||
| 10 | readonly openPendingTask: (pendingTask: PendingNewTask) => void; | ||
| 11 | readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void; | ||
| 12 | } { | ||
| 13 | const navigation = useNavigation(); | ||
| 14 | |||
| 15 | const openPendingTask = useCallback( | ||
| 16 | (pendingTask: PendingNewTask) => { | ||
| 17 | navigation.navigate("NewTaskSheet", { | ||
| 18 | screen: "NewTaskDraft", | ||
| 19 | params: { | ||
| 20 | environmentId: String(pendingTask.message.environmentId), | ||
| 21 | projectId: String(pendingTask.creation.projectId), | ||
| 22 | pendingTaskId: String(pendingTask.message.messageId), | ||
| 23 | }, | ||
| 24 | }); | ||
| 25 | }, | ||
| 26 | [navigation], | ||
| 27 | ); | ||
| 28 | |||
| 29 | const confirmDeletePendingTask = useCallback((pendingTask: PendingNewTask) => { | ||
| 30 | Alert.alert( | ||
| 31 | "Delete pending task?", | ||
| 32 | `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`, | ||
| 33 | [ | ||
| 34 | { text: "Cancel", style: "cancel" }, | ||
| 35 | { | ||
| 36 | text: "Delete", | ||
| 37 | style: "destructive", | ||
| 38 | onPress: () => { | ||
| 39 | // Release the edit lock only after removal succeeds, and only if | ||
| 40 | // it is held for THIS task — clearing it up front (or for another | ||
| 41 | // task) would let the drain deliver a mid-edit payload. | ||
| 42 | void removeThreadOutboxMessage(pendingTask.message) | ||
| 43 | .then(() => releaseEditingQueuedMessage(pendingTask.message.messageId)) | ||
| 44 | .catch((error) => { | ||
| 45 | Alert.alert( | ||
| 46 | "Could not delete pending task", | ||
| 47 | error instanceof Error ? error.message : "The pending task could not be removed.", | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | }, | ||
| 51 | }, | ||
| 52 | ], | ||
| 53 | ); | ||
| 54 | }, []); | ||
| 55 | |||
| 56 | return { openPendingTask, confirmDeletePendingTask }; | ||
| 57 | } | ||
apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx+19-6| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { StaticScreenProps } from "@react-navigation/native"; | 1 | import type { StaticScreenProps } from "@react-navigation/native"; |
| 2 | import { useMemo } from "react"; | ||
| 2 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 3 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 3 | 4 | ||
| 4 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; | 5 | import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; |
| ... | @@ -7,11 +8,25 @@ type NewTaskDraftRouteParams = { | ... | @@ -7,11 +8,25 @@ type NewTaskDraftRouteParams = { |
| 7 | readonly environmentId?: string | string[]; | 8 | readonly environmentId?: string | string[]; |
| 8 | readonly projectId?: string | string[]; | 9 | readonly projectId?: string | string[]; |
| 9 | readonly title?: string | string[]; | 10 | readonly title?: string | string[]; |
| 11 | readonly pendingTaskId?: string | string[]; | ||
| 10 | }; | 12 | }; |
| 11 | 13 | ||
| 12 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { | 14 | export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraftRouteParams>) { |
| 13 | const params = route.params ?? {}; | 15 | const params = route.params ?? {}; |
| 14 | 16 | ||
| 17 | // Keyed on the params object so a fresh navigation to this (already | ||
| 18 | // mounted) screen produces a new reference, letting the draft screen | ||
| 19 | // re-apply the requested project. | ||
| 20 | const initialProjectRef = useMemo( | ||
| 21 | () => ({ | ||
| 22 | environmentId: Array.isArray(params.environmentId) | ||
| 23 | ? params.environmentId[0] | ||
| 24 | : params.environmentId, | ||
| 25 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | ||
| 26 | }), | ||
| 27 | [route.params], | ||
| 28 | ); | ||
| 29 | |||
| 15 | return ( | 30 | return ( |
| 16 | <> | 31 | <> |
| 17 | <NativeStackScreenOptions | 32 | <NativeStackScreenOptions |
| ... | @@ -20,12 +35,10 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf | ... | @@ -20,12 +35,10 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps<NewTaskDraf |
| 20 | }} | 35 | }} |
| 21 | /> | 36 | /> |
| 22 | <NewTaskDraftScreen | 37 | <NewTaskDraftScreen |
| 23 | initialProjectRef={{ | 38 | initialProjectRef={initialProjectRef} |
| 24 | environmentId: Array.isArray(params.environmentId) | 39 | pendingTaskId={ |
| 25 | ? params.environmentId[0] | 40 | Array.isArray(params.pendingTaskId) ? params.pendingTaskId[0] : params.pendingTaskId |
| 26 | : params.environmentId, | 41 | } |
| 27 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | ||
| 28 | }} | ||
| 29 | /> | 42 | /> |
| 30 | </> | 43 | </> |
| 31 | ); | 44 | ); |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+156-11| ... | @@ -23,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri | ... | @@ -23,6 +23,7 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri |
| 23 | import { ControlPillMenu } from "../../components/ControlPill"; | 23 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 24 | import { ProviderIcon } from "../../components/ProviderIcon"; | 24 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 25 | 25 | ||
| 26 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | ||
| 26 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 27 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| 27 | import { | 28 | import { |
| 28 | applyProviderOptionMenuEvent, | 29 | applyProviderOptionMenuEvent, |
| ... | @@ -30,10 +31,11 @@ import { | ... | @@ -30,10 +31,11 @@ import { |
| 30 | providerOptionsConfigurationLabel, | 31 | providerOptionsConfigurationLabel, |
| 31 | resolveProviderOptionDescriptors, | 32 | resolveProviderOptionDescriptors, |
| 32 | } from "../../lib/providerOptions"; | 33 | } from "../../lib/providerOptions"; |
| 33 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 34 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; | 35 | import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; |
| 36 | import { useProjects } from "../../state/entities"; | 36 | import { useProjects } from "../../state/entities"; |
| 37 | import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; | ||
| 38 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; | ||
| 37 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 39 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 38 | import { useCreateProjectThread } from "./use-project-actions"; | 40 | import { useCreateProjectThread } from "./use-project-actions"; |
| 39 | 41 | ||
| ... | @@ -54,6 +56,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -54,6 +56,8 @@ export function NewTaskDraftScreen(props: { |
| 54 | readonly environmentId?: string; | 56 | readonly environmentId?: string; |
| 55 | readonly projectId?: string; | 57 | readonly projectId?: string; |
| 56 | }; | 58 | }; |
| 59 | /** Queued outbox message id when editing an existing pending task. */ | ||
| 60 | readonly pendingTaskId?: string; | ||
| 57 | }) { | 61 | }) { |
| 58 | const projects = useProjects(); | 62 | const projects = useProjects(); |
| 59 | const createProjectThread = useCreateProjectThread(); | 63 | const createProjectThread = useCreateProjectThread(); |
| ... | @@ -64,15 +68,67 @@ export function NewTaskDraftScreen(props: { | ... | @@ -64,15 +68,67 @@ export function NewTaskDraftScreen(props: { |
| 64 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 68 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 65 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); | 69 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 66 | const { logicalProjects, selectedProject, setProject } = flow; | 70 | const { logicalProjects, selectedProject, setProject } = flow; |
| 71 | const { connectedEnvironments } = useRemoteConnectionStatus(); | ||
| 72 | const environmentConnected = | ||
| 73 | selectedProject !== null && | ||
| 74 | connectedEnvironments.find( | ||
| 75 | (environment) => environment.environmentId === selectedProject.environmentId, | ||
| 76 | )?.connectionState === "connected"; | ||
| 67 | const promptInputRef = useRef<ComposerEditorHandle>(null); | 77 | const promptInputRef = useRef<ComposerEditorHandle>(null); |
| 68 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); | 78 | const loadedBranchesProjectKeyRef = useRef<string | null>(null); |
| 79 | const appliedInitialProjectKeyRef = useRef<string | null>(null); | ||
| 80 | useEffect(() => { | ||
| 81 | return () => { | ||
| 82 | appliedInitialProjectKeyRef.current = null; | ||
| 83 | }; | ||
| 84 | }, []); | ||
| 85 | |||
| 86 | const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow; | ||
| 87 | const attemptedPendingTaskIdRef = useRef<string | null>(null); | ||
| 88 | useEffect(() => { | ||
| 89 | if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { | ||
| 90 | return; | ||
| 91 | } | ||
| 92 | // Attempt each pending task once: after it is delivered or deleted the | ||
| 93 | // editing session legitimately ends, and re-running must not navigate. | ||
| 94 | if (attemptedPendingTaskIdRef.current === props.pendingTaskId) { | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | attemptedPendingTaskIdRef.current = props.pendingTaskId; | ||
| 98 | if (!beginEditingPendingTask(props.pendingTaskId)) { | ||
| 99 | // The queued task no longer exists (sent or deleted before opening). | ||
| 100 | navigation.dispatch(StackActions.replace("NewTask")); | ||
| 101 | } | ||
| 102 | }, [beginEditingPendingTask, editingPendingTask?.messageId, navigation, props.pendingTaskId]); | ||
| 103 | |||
| 104 | useEffect(() => { | ||
| 105 | if (!props.pendingTaskId) return; | ||
| 106 | return () => { | ||
| 107 | // Allow a later navigation for the same pending task to re-hydrate it. | ||
| 108 | attemptedPendingTaskIdRef.current = null; | ||
| 109 | cancelEditingPendingTask(); | ||
| 110 | }; | ||
| 111 | }, [props.pendingTaskId, cancelEditingPendingTask]); | ||
| 69 | 112 | ||
| 70 | const borderColor = useThemeColor("--color-border"); | 113 | const borderColor = useThemeColor("--color-border"); |
| 71 | const bodyText = useScaledTextRole("body"); | 114 | const bodyText = useScaledTextRole("body"); |
| 72 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | 115 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 73 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; | 116 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; |
| 74 | 117 | ||
| 118 | // A new navigation to this mounted screen delivers a fresh initialProjectRef | ||
| 119 | // reference — treat it as a new request and let it apply again. | ||
| 120 | const lastInitialProjectRefRef = useRef(props.initialProjectRef); | ||
| 121 | |||
| 75 | useEffect(() => { | 122 | useEffect(() => { |
| 123 | // Pending-task editing owns project selection (and must not fall through | ||
| 124 | // to the replace("NewTask") fallback while its hydration is in flight). | ||
| 125 | if (props.pendingTaskId) { | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | if (lastInitialProjectRefRef.current !== props.initialProjectRef) { | ||
| 129 | lastInitialProjectRefRef.current = props.initialProjectRef; | ||
| 130 | appliedInitialProjectKeyRef.current = null; | ||
| 131 | } | ||
| 76 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { | 132 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { |
| 77 | const directProject = | 133 | const directProject = |
| 78 | projects.find( | 134 | projects.find( |
| ... | @@ -82,6 +138,13 @@ export function NewTaskDraftScreen(props: { | ... | @@ -82,6 +138,13 @@ export function NewTaskDraftScreen(props: { |
| 82 | ) ?? null; | 138 | ) ?? null; |
| 83 | 139 | ||
| 84 | if (directProject) { | 140 | if (directProject) { |
| 141 | // Apply the route's project once. Re-applying on every change would | ||
| 142 | // instantly revert environment/project switches made in the picker. | ||
| 143 | const directProjectKey = `${directProject.environmentId}:${directProject.id}`; | ||
| 144 | if (appliedInitialProjectKeyRef.current === directProjectKey) { | ||
| 145 | return; | ||
| 146 | } | ||
| 147 | appliedInitialProjectKeyRef.current = directProjectKey; | ||
| 85 | if ( | 148 | if ( |
| 86 | selectedProject?.environmentId === directProject.environmentId && | 149 | selectedProject?.environmentId === directProject.environmentId && |
| 87 | selectedProject.id === directProject.id | 150 | selectedProject.id === directProject.id |
| ... | @@ -106,8 +169,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -106,8 +169,8 @@ export function NewTaskDraftScreen(props: { |
| 106 | }, [ | 169 | }, [ |
| 107 | logicalProjects, | 170 | logicalProjects, |
| 108 | projects, | 171 | projects, |
| 109 | props.initialProjectRef?.environmentId, | 172 | props.initialProjectRef, |
| 110 | props.initialProjectRef?.projectId, | 173 | props.pendingTaskId, |
| 111 | navigation, | 174 | navigation, |
| 112 | selectedProject, | 175 | selectedProject, |
| 113 | setProject, | 176 | setProject, |
| ... | @@ -274,12 +337,24 @@ export function NewTaskDraftScreen(props: { | ... | @@ -274,12 +337,24 @@ export function NewTaskDraftScreen(props: { |
| 274 | subtitle: flow.selectedBranchName ?? "Choose branch", | 337 | subtitle: flow.selectedBranchName ?? "Choose branch", |
| 275 | subactions: branchActions, | 338 | subactions: branchActions, |
| 276 | }, | 339 | }, |
| 340 | ...(flow.workspaceMode === "worktree" | ||
| 341 | ? [ | ||
| 342 | { | ||
| 343 | id: "workspace:start-from-origin", | ||
| 344 | title: "Start from origin", | ||
| 345 | subtitle: "Base the worktree on the latest origin branch", | ||
| 346 | image: "arrow.triangle.pull", | ||
| 347 | state: flow.startFromOrigin ? ("on" as const) : undefined, | ||
| 348 | }, | ||
| 349 | ] | ||
| 350 | : []), | ||
| 277 | ]; | 351 | ]; |
| 278 | }, [ | 352 | }, [ |
| 279 | flow.availableBranches, | 353 | flow.availableBranches, |
| 280 | flow.branchesLoading, | 354 | flow.branchesLoading, |
| 281 | flow.selectedBranchName, | 355 | flow.selectedBranchName, |
| 282 | flow.selectedProject, | 356 | flow.selectedProject, |
| 357 | flow.startFromOrigin, | ||
| 283 | flow.workspaceMode, | 358 | flow.workspaceMode, |
| 284 | ]); | 359 | ]); |
| 285 | 360 | ||
| ... | @@ -344,6 +419,10 @@ export function NewTaskDraftScreen(props: { | ... | @@ -344,6 +419,10 @@ export function NewTaskDraftScreen(props: { |
| 344 | ); | 419 | ); |
| 345 | return; | 420 | return; |
| 346 | } | 421 | } |
| 422 | if (event === "workspace:start-from-origin") { | ||
| 423 | flow.setStartFromOrigin(!flow.startFromOrigin); | ||
| 424 | return; | ||
| 425 | } | ||
| 347 | if (event.startsWith("workspace:branch:")) { | 426 | if (event.startsWith("workspace:branch:")) { |
| 348 | const branchName = event.slice("workspace:branch:".length); | 427 | const branchName = event.slice("workspace:branch:".length); |
| 349 | const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); | 428 | const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); |
| ... | @@ -379,17 +458,17 @@ export function NewTaskDraftScreen(props: { | ... | @@ -379,17 +458,17 @@ export function NewTaskDraftScreen(props: { |
| 379 | 458 | ||
| 380 | async function handleStart(): Promise<void> { | 459 | async function handleStart(): Promise<void> { |
| 381 | const selectedProject = flow.selectedProject; | 460 | const selectedProject = flow.selectedProject; |
| 382 | if (!selectedProject) { | 461 | const draftKey = flow.draftKey; |
| 462 | if (!selectedProject || !draftKey) { | ||
| 383 | return; | 463 | return; |
| 384 | } | 464 | } |
| 385 | const draft = getComposerDraftSnapshot( | 465 | const draft = getComposerDraftSnapshot(draftKey); |
| 386 | `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, | ||
| 387 | ); | ||
| 388 | const modelSelection = draft.modelSelection ?? flow.selectedModel; | 466 | const modelSelection = draft.modelSelection ?? flow.selectedModel; |
| 389 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; | 467 | const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; |
| 390 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; | 468 | const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; |
| 391 | const selectedWorktreePath = | 469 | const selectedWorktreePath = |
| 392 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; | 470 | draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; |
| 471 | const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin; | ||
| 393 | const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; | 472 | const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; |
| 394 | const interactionMode = draft.interactionMode ?? flow.interactionMode; | 473 | const interactionMode = draft.interactionMode ?? flow.interactionMode; |
| 395 | const initialMessageText = draft.text.trim(); | 474 | const initialMessageText = draft.text.trim(); |
| ... | @@ -403,6 +482,46 @@ export function NewTaskDraftScreen(props: { | ... | @@ -403,6 +482,46 @@ export function NewTaskDraftScreen(props: { |
| 403 | return; | 482 | return; |
| 404 | } | 483 | } |
| 405 | 484 | ||
| 485 | const editingPendingTask = flow.editingPendingTask; | ||
| 486 | |||
| 487 | if (!environmentConnected) { | ||
| 488 | // Offline: park the task in the outbox; the drain sends it when the | ||
| 489 | // environment reconnects. Editing an existing pending task re-queues it | ||
| 490 | // under its original identifiers. | ||
| 491 | const metadata = editingPendingTask | ||
| 492 | ? { | ||
| 493 | threadId: editingPendingTask.threadId, | ||
| 494 | commandId: editingPendingTask.commandId, | ||
| 495 | messageId: editingPendingTask.messageId, | ||
| 496 | createdAt: editingPendingTask.createdAt, | ||
| 497 | } | ||
| 498 | : makeTurnCommandMetadata(); | ||
| 499 | const message = flow.buildPendingTaskMessage(metadata); | ||
| 500 | if (!message) { | ||
| 501 | return; | ||
| 502 | } | ||
| 503 | flow.setSubmitting(true); | ||
| 504 | try { | ||
| 505 | await enqueueThreadOutboxMessage(message); | ||
| 506 | } catch (error) { | ||
| 507 | Alert.alert( | ||
| 508 | "Could not queue task", | ||
| 509 | error instanceof Error ? error.message : "The task could not be saved to the outbox.", | ||
| 510 | ); | ||
| 511 | return; | ||
| 512 | } finally { | ||
| 513 | flow.setSubmitting(false); | ||
| 514 | } | ||
| 515 | if (editingPendingTask) { | ||
| 516 | flow.finishEditingPendingTask(); | ||
| 517 | } else { | ||
| 518 | flow.setPrompt(""); | ||
| 519 | flow.clearAttachments(); | ||
| 520 | } | ||
| 521 | navigation.getParent()?.goBack(); | ||
| 522 | return; | ||
| 523 | } | ||
| 524 | |||
| 406 | flow.setSubmitting(true); | 525 | flow.setSubmitting(true); |
| 407 | const result = await createProjectThread({ | 526 | const result = await createProjectThread({ |
| 408 | project: selectedProject, | 527 | project: selectedProject, |
| ... | @@ -410,10 +529,21 @@ export function NewTaskDraftScreen(props: { | ... | @@ -410,10 +529,21 @@ export function NewTaskDraftScreen(props: { |
| 410 | envMode: workspaceMode, | 529 | envMode: workspaceMode, |
| 411 | branch: selectedBranchName, | 530 | branch: selectedBranchName, |
| 412 | worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, | 531 | worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, |
| 532 | startFromOrigin, | ||
| 413 | runtimeMode, | 533 | runtimeMode, |
| 414 | interactionMode, | 534 | interactionMode, |
| 415 | initialMessageText, | 535 | initialMessageText, |
| 416 | initialAttachments: draft.attachments, | 536 | initialAttachments: draft.attachments, |
| 537 | ...(editingPendingTask | ||
| 538 | ? { | ||
| 539 | turnMetadata: { | ||
| 540 | threadId: editingPendingTask.threadId, | ||
| 541 | commandId: editingPendingTask.commandId, | ||
| 542 | messageId: editingPendingTask.messageId, | ||
| 543 | createdAt: editingPendingTask.createdAt, | ||
| 544 | }, | ||
| 545 | } | ||
| 546 | : {}), | ||
| 417 | }); | 547 | }); |
| 418 | flow.setSubmitting(false); | 548 | flow.setSubmitting(false); |
| 419 | 549 | ||
| ... | @@ -428,8 +558,17 @@ export function NewTaskDraftScreen(props: { | ... | @@ -428,8 +558,17 @@ export function NewTaskDraftScreen(props: { |
| 428 | return; | 558 | return; |
| 429 | } | 559 | } |
| 430 | 560 | ||
| 431 | flow.setPrompt(""); | 561 | if (editingPendingTask) { |
| 432 | flow.clearAttachments(); | 562 | try { |
| 563 | await removeThreadOutboxMessage(editingPendingTask); | ||
| 564 | } catch (error) { | ||
| 565 | console.warn("[new-task] failed to remove delivered pending task", error); | ||
| 566 | } | ||
| 567 | flow.finishEditingPendingTask(); | ||
| 568 | } else { | ||
| 569 | flow.setPrompt(""); | ||
| 570 | flow.clearAttachments(); | ||
| 571 | } | ||
| 433 | navigation.dispatch( | 572 | navigation.dispatch( |
| 434 | StackActions.replace("Thread", { | 573 | StackActions.replace("Thread", { |
| 435 | environmentId: String(result.value.environmentId), | 574 | environmentId: String(result.value.environmentId), |
| ... | @@ -538,8 +677,14 @@ export function NewTaskDraftScreen(props: { | ... | @@ -538,8 +677,14 @@ export function NewTaskDraftScreen(props: { |
| 538 | </ControlPillMenu> | 677 | </ControlPillMenu> |
| 539 | </ComposerToolbarScroller> | 678 | </ComposerToolbarScroller> |
| 540 | <ComposerToolbarButton | 679 | <ComposerToolbarButton |
| 541 | accessibilityLabel={flow.submitting ? "Starting task" : "Start task"} | 680 | accessibilityLabel={ |
| 542 | icon="arrow.up" | 681 | flow.submitting |
| 682 | ? "Starting task" | ||
| 683 | : environmentConnected | ||
| 684 | ? "Start task" | ||
| 685 | : "Queue task" | ||
| 686 | } | ||
| 687 | icon={environmentConnected ? "arrow.up" : "tray.and.arrow.up"} | ||
| 543 | onPress={() => void handleStart()} | 688 | onPress={() => void handleStart()} |
| 544 | variant="primary" | 689 | variant="primary" |
| 545 | showChevron={false} | 690 | showChevron={false} |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+29-3| ... | @@ -18,6 +18,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -18,6 +18,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 18 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; | 18 | import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; |
| 19 | import { useThemeColor } from "../../lib/useThemeColor"; | 19 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 20 | import { useProjects, useThreadShells } from "../../state/entities"; | 20 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 21 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | ||
| 21 | import { useWorkspaceState } from "../../state/workspace"; | 22 | import { useWorkspaceState } from "../../state/workspace"; |
| 22 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 23 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 23 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | 24 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; |
| ... | @@ -40,6 +41,7 @@ import { | ... | @@ -40,6 +41,7 @@ import { |
| 40 | } from "../home/homeListItems"; | 41 | } from "../home/homeListItems"; |
| 41 | import { buildHomeThreadGroups } from "../home/homeThreadList"; | 42 | import { buildHomeThreadGroups } from "../home/homeThreadList"; |
| 42 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; | 43 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; |
| 44 | import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; | ||
| 43 | import { useThreadListActions } from "../home/useThreadListActions"; | 45 | import { useThreadListActions } from "../home/useThreadListActions"; |
| 44 | import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; | 46 | import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; |
| 45 | import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; | 47 | import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; |
| ... | @@ -47,7 +49,12 @@ import { SidebarHeaderActions } from "./sidebar-header-actions"; | ... | @@ -47,7 +49,12 @@ import { SidebarHeaderActions } from "./sidebar-header-actions"; |
| 47 | import { SidebarFilterButton } from "./sidebar-filter-button"; | 49 | import { SidebarFilterButton } from "./sidebar-filter-button"; |
| 48 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; | 50 | import { createSidebarHeaderItems } from "./sidebar-native-header-items"; |
| 49 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; | 51 | import { SidebarNavigationShell } from "./sidebar-navigation-shell"; |
| 50 | import { ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow } from "./thread-list-items"; | 52 | import { |
| 53 | PendingTaskListRow, | ||
| 54 | ThreadListGroupHeader, | ||
| 55 | ThreadListRow, | ||
| 56 | ThreadListShowMoreRow, | ||
| 57 | } from "./thread-list-items"; | ||
| 51 | 58 | ||
| 52 | /** | 59 | /** |
| 53 | * Shared capsule behind the sidebar header buttons — a native liquid-glass | 60 | * Shared capsule behind the sidebar header buttons — a native liquid-glass |
| ... | @@ -162,6 +169,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -162,6 +169,8 @@ function ThreadNavigationSidebarPane( |
| 162 | const headerIsOverContentRef = useRef(false); | 169 | const headerIsOverContentRef = useRef(false); |
| 163 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); | 170 | const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); |
| 164 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); | 171 | const { archiveThread, confirmDeleteThread } = useThreadListActions(); |
| 172 | const pendingTasks = usePendingNewTasks(); | ||
| 173 | const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); | ||
| 165 | const environments = useMemo( | 174 | const environments = useMemo( |
| 166 | () => | 175 | () => |
| 167 | Object.values(savedConnectionsById) | 176 | Object.values(savedConnectionsById) |
| ... | @@ -188,13 +197,14 @@ function ThreadNavigationSidebarPane( | ... | @@ -188,13 +197,14 @@ function ThreadNavigationSidebarPane( |
| 188 | buildHomeThreadGroups({ | 197 | buildHomeThreadGroups({ |
| 189 | projects, | 198 | projects, |
| 190 | threads, | 199 | threads, |
| 200 | pendingTasks, | ||
| 191 | environmentId: options.selectedEnvironmentId, | 201 | environmentId: options.selectedEnvironmentId, |
| 192 | searchQuery: props.searchQuery, | 202 | searchQuery: props.searchQuery, |
| 193 | projectSortOrder: options.projectSortOrder, | 203 | projectSortOrder: options.projectSortOrder, |
| 194 | threadSortOrder: options.threadSortOrder, | 204 | threadSortOrder: options.threadSortOrder, |
| 195 | projectGroupingMode: options.projectGroupingMode, | 205 | projectGroupingMode: options.projectGroupingMode, |
| 196 | }), | 206 | }), |
| 197 | [options, projects, props.searchQuery, threads], | 207 | [options, pendingTasks, projects, props.searchQuery, threads], |
| 198 | ); | 208 | ); |
| 199 | const [groupDisplayStates, setGroupDisplayStates] = useState< | 209 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 200 | ReadonlyMap<string, HomeGroupDisplayState> | 210 | ReadonlyMap<string, HomeGroupDisplayState> |
| ... | @@ -404,10 +414,24 @@ function ThreadNavigationSidebarPane( | ... | @@ -404,10 +414,24 @@ function ThreadNavigationSidebarPane( |
| 404 | groupKey={item.group.key} | 414 | groupKey={item.group.key} |
| 405 | onGroupAction={updateGroupDisplay} | 415 | onGroupAction={updateGroupDisplay} |
| 406 | project={item.group.representative} | 416 | project={item.group.representative} |
| 407 | threadCount={item.group.threads.length} | 417 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 408 | title={item.group.title} | 418 | title={item.group.title} |
| 409 | /> | 419 | /> |
| 410 | ); | 420 | ); |
| 421 | case "pending-task": | ||
| 422 | return ( | ||
| 423 | <PendingTaskListRow | ||
| 424 | variant="sidebar" | ||
| 425 | pendingTask={item.pendingTask} | ||
| 426 | environmentLabel={ | ||
| 427 | savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? | ||
| 428 | null | ||
| 429 | } | ||
| 430 | isLast={item.isLast} | ||
| 431 | onSelectPendingTask={openPendingTask} | ||
| 432 | onDeletePendingTask={confirmDeletePendingTask} | ||
| 433 | /> | ||
| 434 | ); | ||
| 411 | case "thread": { | 435 | case "thread": { |
| 412 | const thread = item.thread; | 436 | const thread = item.thread; |
| 413 | return ( | 437 | return ( |
| ... | @@ -449,10 +473,12 @@ function ThreadNavigationSidebarPane( | ... | @@ -449,10 +473,12 @@ function ThreadNavigationSidebarPane( |
| 449 | }, | 473 | }, |
| 450 | [ | 474 | [ |
| 451 | archiveThread, | 475 | archiveThread, |
| 476 | confirmDeletePendingTask, | ||
| 452 | confirmDeleteThread, | 477 | confirmDeleteThread, |
| 453 | handleSelectThread, | 478 | handleSelectThread, |
| 454 | handleSwipeableClose, | 479 | handleSwipeableClose, |
| 455 | handleSwipeableWillOpen, | 480 | handleSwipeableWillOpen, |
| 481 | openPendingTask, | ||
| 456 | projectCwdByKey, | 482 | projectCwdByKey, |
| 457 | props.selectedThreadKey, | 483 | props.selectedThreadKey, |
| 458 | props.width, | 484 | props.width, |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+318-10| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import React, { useCallback, useEffect, useMemo, useState } from "react"; | 1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import type { |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| ... | @@ -8,18 +8,29 @@ import type { | ... | @@ -8,18 +8,29 @@ import type { |
| 8 | RuntimeMode, | 8 | RuntimeMode, |
| 9 | ServerProviderSkill, | 9 | ServerProviderSkill, |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; | 11 | import { |
| 12 | CommandId, | ||
| 13 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 14 | DEFAULT_RUNTIME_MODE, | ||
| 15 | MessageId, | ||
| 16 | ThreadId, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 12 | import * as Arr from "effect/Array"; | 18 | import * as Arr from "effect/Array"; |
| 13 | import { pipe } from "effect/Function"; | 19 | import { pipe } from "effect/Function"; |
| 14 | 20 | ||
| 15 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; | 21 | import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; |
| 22 | import type { TurnCommandMetadata } from "../../lib/commandMetadata"; | ||
| 16 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 23 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 17 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | 24 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 18 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 25 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 19 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 26 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 20 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 27 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 28 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 21 | import { | 29 | import { |
| 22 | appendComposerDraftAttachments, | 30 | appendComposerDraftAttachments, |
| 31 | clearComposerDraft, | ||
| 32 | getComposerDraftSnapshot, | ||
| 33 | isComposerDraftEmpty, | ||
| 23 | removeComposerDraftAttachment, | 34 | removeComposerDraftAttachment, |
| 24 | replaceComposerDraftAttachments, | 35 | replaceComposerDraftAttachments, |
| 25 | setComposerDraftText, | 36 | setComposerDraftText, |
| ... | @@ -27,6 +38,17 @@ import { | ... | @@ -27,6 +38,17 @@ import { |
| 27 | useComposerDraft, | 38 | useComposerDraft, |
| 28 | } from "../../state/use-composer-drafts"; | 39 | } from "../../state/use-composer-drafts"; |
| 29 | import { useBranches } from "../../state/queries"; | 40 | import { useBranches } from "../../state/queries"; |
| 41 | import { | ||
| 42 | flattenQueuedThreadMessages, | ||
| 43 | threadOutboxManager, | ||
| 44 | updateThreadOutboxMessage, | ||
| 45 | type QueuedThreadMessage, | ||
| 46 | } from "../../state/thread-outbox"; | ||
| 47 | import { | ||
| 48 | holdEditingQueuedMessage, | ||
| 49 | releaseEditingQueuedMessage, | ||
| 50 | useThreadOutboxMessages, | ||
| 51 | } from "../../state/use-thread-outbox"; | ||
| 30 | import { | 52 | import { |
| 31 | setPendingConnectionError, | 53 | setPendingConnectionError, |
| 32 | useSavedRemoteConnections, | 54 | useSavedRemoteConnections, |
| ... | @@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; | ... | @@ -36,6 +58,23 @@ import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 36 | 58 | ||
| 37 | type WorkspaceMode = "local" | "worktree"; | 59 | type WorkspaceMode = "local" | "worktree"; |
| 38 | 60 | ||
| 61 | function pendingTaskDraftKey(messageId: string): string { | ||
| 62 | return `pending-task:${messageId}`; | ||
| 63 | } | ||
| 64 | |||
| 65 | // The message id owned by the currently active editing session, tracked | ||
| 66 | // across provider instances. An in-flight flush from a dismissed session | ||
| 67 | // consults it so it never drops the draft or releases the drain lock out from | ||
| 68 | // under a newer session editing the same task. | ||
| 69 | let activeEditingMessageId: string | null = null; | ||
| 70 | |||
| 71 | function findQueuedPendingTask(messageId: string): QueuedThreadMessage | null { | ||
| 72 | const message = flattenQueuedThreadMessages( | ||
| 73 | appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), | ||
| 74 | ).find((candidate) => candidate.messageId === messageId); | ||
| 75 | return message?.creation !== undefined ? message : null; | ||
| 76 | } | ||
| 77 | |||
| 39 | function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { | 78 | function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { |
| 40 | if (!branch.worktreePath) { | 79 | if (!branch.worktreePath) { |
| 41 | return null; | 80 | return null; |
| ... | @@ -74,6 +113,9 @@ type NewTaskFlowContextValue = { | ... | @@ -74,6 +113,9 @@ type NewTaskFlowContextValue = { |
| 74 | readonly workspaceMode: WorkspaceMode; | 113 | readonly workspaceMode: WorkspaceMode; |
| 75 | readonly selectedBranchName: string | null; | 114 | readonly selectedBranchName: string | null; |
| 76 | readonly selectedWorktreePath: string | null; | 115 | readonly selectedWorktreePath: string | null; |
| 116 | readonly startFromOrigin: boolean; | ||
| 117 | readonly draftKey: string | null; | ||
| 118 | readonly editingPendingTask: QueuedThreadMessage | null; | ||
| 77 | readonly prompt: string; | 119 | readonly prompt: string; |
| 78 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | 120 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 79 | readonly submitting: boolean; | 121 | readonly submitting: boolean; |
| ... | @@ -100,6 +142,11 @@ type NewTaskFlowContextValue = { | ... | @@ -100,6 +142,11 @@ type NewTaskFlowContextValue = { |
| 100 | readonly setSelectedModelKey: (key: string | null) => void; | 142 | readonly setSelectedModelKey: (key: string | null) => void; |
| 101 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; | 143 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; |
| 102 | readonly selectBranch: (branch: VcsRef) => void; | 144 | readonly selectBranch: (branch: VcsRef) => void; |
| 145 | readonly setStartFromOrigin: (value: boolean) => void; | ||
| 146 | readonly beginEditingPendingTask: (messageId: string) => boolean; | ||
| 147 | readonly finishEditingPendingTask: () => void; | ||
| 148 | readonly cancelEditingPendingTask: () => void; | ||
| 149 | readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; | ||
| 103 | readonly setPrompt: (value: string) => void; | 150 | readonly setPrompt: (value: string) => void; |
| 104 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | 151 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; |
| 105 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | 152 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; |
| ... | @@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -162,6 +209,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 162 | const [submitting, setSubmitting] = useState(false); | 209 | const [submitting, setSubmitting] = useState(false); |
| 163 | const [branchQuery, setBranchQuery] = useState(""); | 210 | const [branchQuery, setBranchQuery] = useState(""); |
| 164 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | 211 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 212 | const [editingPendingTask, setEditingPendingTask] = useState<QueuedThreadMessage | null>(null); | ||
| 213 | // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act | ||
| 214 | // on a task whose editing session already ended this render. | ||
| 215 | const editingPendingTaskRef = useRef<QueuedThreadMessage | null>(null); | ||
| 165 | 216 | ||
| 166 | const reset = useCallback(() => { | 217 | const reset = useCallback(() => { |
| 167 | setSelectedEnvironmentId(null); | 218 | setSelectedEnvironmentId(null); |
| ... | @@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -169,6 +220,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 169 | setSubmitting(false); | 220 | setSubmitting(false); |
| 170 | setBranchQuery(""); | 221 | setBranchQuery(""); |
| 171 | setExpandedProvider(null); | 222 | setExpandedProvider(null); |
| 223 | const editing = editingPendingTaskRef.current; | ||
| 224 | editingPendingTaskRef.current = null; | ||
| 225 | setEditingPendingTask(null); | ||
| 226 | if (editing) { | ||
| 227 | if (activeEditingMessageId === editing.messageId) { | ||
| 228 | activeEditingMessageId = null; | ||
| 229 | } | ||
| 230 | releaseEditingQueuedMessage(editing.messageId); | ||
| 231 | } | ||
| 172 | }, []); | 232 | }, []); |
| 173 | 233 | ||
| 174 | const environments = useMemo( | 234 | const environments = useMemo( |
| ... | @@ -214,24 +274,59 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -214,24 +274,59 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 214 | [projects, selectedEnvironmentId], | 274 | [projects, selectedEnvironmentId], |
| 215 | ); | 275 | ); |
| 216 | 276 | ||
| 277 | // Stand-in for the edited task's project while its shell is not loaded | ||
| 278 | // (environment offline / still synchronizing), built from the metadata | ||
| 279 | // snapshotted at enqueue time. | ||
| 280 | const editingPendingProject = useMemo<EnvironmentProject | null>(() => { | ||
| 281 | const creation = editingPendingTask?.creation; | ||
| 282 | if (!editingPendingTask || !creation) { | ||
| 283 | return null; | ||
| 284 | } | ||
| 285 | return { | ||
| 286 | environmentId: editingPendingTask.environmentId, | ||
| 287 | id: creation.projectId, | ||
| 288 | title: creation.projectTitle ?? "Unknown project", | ||
| 289 | // Deliberately empty when the snapshot has no cwd — downstream consumers | ||
| 290 | // (branch queries, worktree bootstrap) must skip it, not receive a | ||
| 291 | // fabricated path. | ||
| 292 | workspaceRoot: creation.projectCwd ?? "", | ||
| 293 | repositoryIdentity: null, | ||
| 294 | defaultModelSelection: editingPendingTask.modelSelection ?? null, | ||
| 295 | scripts: [], | ||
| 296 | createdAt: editingPendingTask.createdAt, | ||
| 297 | updatedAt: editingPendingTask.createdAt, | ||
| 298 | }; | ||
| 299 | }, [editingPendingTask]); | ||
| 300 | |||
| 217 | const selectedProject = | 301 | const selectedProject = |
| 218 | projectsForEnvironment.find( | 302 | projectsForEnvironment.find( |
| 219 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, | 303 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, |
| 220 | ) ?? | 304 | ) ?? |
| 221 | projectsForEnvironment[0] ?? | 305 | // While editing a queued task whose project shell is absent, keep the task |
| 222 | null; | 306 | // pinned to its own project — falling through to an arbitrary first |
| 307 | // project would silently retarget it (and its reused turn identifiers). | ||
| 308 | (editingPendingProject !== null && | ||
| 309 | selectedProjectKey === | ||
| 310 | scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) | ||
| 311 | ? editingPendingProject | ||
| 312 | : (projectsForEnvironment[0] ?? null)); | ||
| 223 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( | 313 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| 224 | selectedProject?.environmentId ?? null, | 314 | selectedProject?.environmentId ?? null, |
| 225 | ); | 315 | ); |
| 226 | const selectedProjectDraftKey = selectedProject | 316 | // While a queued pending task is being edited its draft lives under a key |
| 227 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | 317 | // scoped to the queued message, so per-project new-task drafts stay intact. |
| 228 | : null; | 318 | const selectedProjectDraftKey = editingPendingTask |
| 319 | ? pendingTaskDraftKey(editingPendingTask.messageId) | ||
| 320 | : selectedProject | ||
| 321 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | ||
| 322 | : null; | ||
| 229 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); | 323 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); |
| 230 | const prompt = selectedProjectDraft.text; | 324 | const prompt = selectedProjectDraft.text; |
| 231 | const attachments = selectedProjectDraft.attachments; | 325 | const attachments = selectedProjectDraft.attachments; |
| 232 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; | 326 | const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; |
| 233 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; | 327 | const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; |
| 234 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; | 328 | const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; |
| 329 | const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; | ||
| 235 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; | 330 | const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; |
| 236 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; | 331 | const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; |
| 237 | 332 | ||
| ... | @@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -350,7 +445,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 350 | const branchTarget = useMemo( | 445 | const branchTarget = useMemo( |
| 351 | () => ({ | 446 | () => ({ |
| 352 | environmentId: selectedProject?.environmentId ?? null, | 447 | environmentId: selectedProject?.environmentId ?? null, |
| 353 | cwd: selectedProject?.workspaceRoot ?? null, | 448 | // `|| null` also skips the stand-in project's empty workspaceRoot. |
| 449 | cwd: selectedProject?.workspaceRoot || null, | ||
| 354 | query: null, | 450 | query: null, |
| 355 | }), | 451 | }), |
| 356 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], | 452 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], |
| ... | @@ -399,10 +495,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -399,10 +495,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 399 | mode, | 495 | mode, |
| 400 | branch: selectedBranchName, | 496 | branch: selectedBranchName, |
| 401 | worktreePath: selectedWorktreePath, | 497 | worktreePath: selectedWorktreePath, |
| 498 | startFromOrigin, | ||
| 402 | }, | 499 | }, |
| 403 | }); | 500 | }); |
| 404 | }, | 501 | }, |
| 405 | [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], | 502 | [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], |
| 406 | ); | 503 | ); |
| 407 | 504 | ||
| 408 | const selectBranch = useCallback( | 505 | const selectBranch = useCallback( |
| ... | @@ -415,10 +512,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -415,10 +512,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 415 | mode: workspaceMode, | 512 | mode: workspaceMode, |
| 416 | branch: branch.name, | 513 | branch: branch.name, |
| 417 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), | 514 | worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), |
| 515 | startFromOrigin, | ||
| 418 | }, | 516 | }, |
| 419 | }); | 517 | }); |
| 420 | }, | 518 | }, |
| 421 | [selectedProject, selectedProjectDraftKey, workspaceMode], | 519 | [selectedProject, selectedProjectDraftKey, startFromOrigin, workspaceMode], |
| 520 | ); | ||
| 521 | |||
| 522 | const setStartFromOrigin = useCallback( | ||
| 523 | (value: boolean) => { | ||
| 524 | if (!selectedProjectDraftKey) { | ||
| 525 | return; | ||
| 526 | } | ||
| 527 | updateComposerDraftSettings(selectedProjectDraftKey, { | ||
| 528 | workspaceSelection: { | ||
| 529 | mode: workspaceMode, | ||
| 530 | branch: selectedBranchName, | ||
| 531 | worktreePath: selectedWorktreePath, | ||
| 532 | startFromOrigin: value, | ||
| 533 | }, | ||
| 534 | }); | ||
| 535 | }, | ||
| 536 | [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode], | ||
| 422 | ); | 537 | ); |
| 423 | 538 | ||
| 424 | const refreshBranches = branchState.refresh; | 539 | const refreshBranches = branchState.refresh; |
| ... | @@ -460,6 +575,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -460,6 +575,183 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 460 | [selectedProjectDraftKey], | 575 | [selectedProjectDraftKey], |
| 461 | ); | 576 | ); |
| 462 | 577 | ||
| 578 | const beginEditingPendingTask = useCallback((messageId: string): boolean => { | ||
| 579 | const message = findQueuedPendingTask(messageId); | ||
| 580 | if (!message?.creation) { | ||
| 581 | return false; | ||
| 582 | } | ||
| 583 | const draftKey = pendingTaskDraftKey(message.messageId); | ||
| 584 | // Only hydrate a fresh editing draft; reopening mid-edit keeps newer edits. | ||
| 585 | if (isComposerDraftEmpty(getComposerDraftSnapshot(draftKey))) { | ||
| 586 | setComposerDraftText(draftKey, message.text); | ||
| 587 | replaceComposerDraftAttachments(draftKey, message.attachments); | ||
| 588 | updateComposerDraftSettings(draftKey, { | ||
| 589 | modelSelection: message.modelSelection, | ||
| 590 | runtimeMode: message.runtimeMode, | ||
| 591 | interactionMode: message.interactionMode, | ||
| 592 | workspaceSelection: { | ||
| 593 | mode: message.creation.workspaceMode, | ||
| 594 | branch: message.creation.branch, | ||
| 595 | worktreePath: message.creation.worktreePath, | ||
| 596 | startFromOrigin: message.creation.startFromOrigin ?? false, | ||
| 597 | }, | ||
| 598 | }); | ||
| 599 | } | ||
| 600 | setSelectedEnvironmentId(message.environmentId); | ||
| 601 | setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); | ||
| 602 | activeEditingMessageId = message.messageId; | ||
| 603 | editingPendingTaskRef.current = message; | ||
| 604 | setEditingPendingTask(message); | ||
| 605 | // Hold the outbox drain off this task while it is open in the editor. | ||
| 606 | holdEditingQueuedMessage(message.messageId); | ||
| 607 | return true; | ||
| 608 | }, []); | ||
| 609 | |||
| 610 | const buildPendingTaskMessage = useCallback( | ||
| 611 | (metadata: TurnCommandMetadata): QueuedThreadMessage | null => { | ||
| 612 | if (!selectedProject || !selectedProjectDraftKey) { | ||
| 613 | return null; | ||
| 614 | } | ||
| 615 | const draft = getComposerDraftSnapshot(selectedProjectDraftKey); | ||
| 616 | const text = draft.text.trim(); | ||
| 617 | const draftModelSelection = draft.modelSelection ?? selectedModel; | ||
| 618 | if (text.length === 0 || !draftModelSelection) { | ||
| 619 | return null; | ||
| 620 | } | ||
| 621 | const workspaceSelection = draft.workspaceSelection; | ||
| 622 | const mode = workspaceSelection?.mode ?? "local"; | ||
| 623 | // When the selection is the stand-in built from the queued snapshot, | ||
| 624 | // persist the original (possibly absent) snapshot values — the | ||
| 625 | // stand-in's placeholder title/workspaceRoot must never be written back | ||
| 626 | // as if they were real project metadata. | ||
| 627 | const usingPendingSnapshot = selectedProject === editingPendingProject; | ||
| 628 | const projectTitle = usingPendingSnapshot | ||
| 629 | ? editingPendingTask?.creation?.projectTitle | ||
| 630 | : selectedProject.title; | ||
| 631 | const projectCwd = usingPendingSnapshot | ||
| 632 | ? editingPendingTask?.creation?.projectCwd | ||
| 633 | : selectedProject.workspaceRoot; | ||
| 634 | return { | ||
| 635 | environmentId: selectedProject.environmentId, | ||
| 636 | threadId: ThreadId.make(metadata.threadId), | ||
| 637 | messageId: MessageId.make(metadata.messageId), | ||
| 638 | commandId: CommandId.make(metadata.commandId), | ||
| 639 | text, | ||
| 640 | attachments: draft.attachments, | ||
| 641 | modelSelection: draftModelSelection, | ||
| 642 | runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, | ||
| 643 | interactionMode: draft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 644 | creation: { | ||
| 645 | projectId: selectedProject.id, | ||
| 646 | ...(projectTitle !== undefined ? { projectTitle } : {}), | ||
| 647 | ...(projectCwd !== undefined ? { projectCwd } : {}), | ||
| 648 | workspaceMode: mode, | ||
| 649 | branch: workspaceSelection?.branch ?? null, | ||
| 650 | worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), | ||
| 651 | ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), | ||
| 652 | }, | ||
| 653 | createdAt: metadata.createdAt, | ||
| 654 | }; | ||
| 655 | }, | ||
| 656 | [ | ||
| 657 | editingPendingProject, | ||
| 658 | editingPendingTask, | ||
| 659 | selectedModel, | ||
| 660 | selectedProject, | ||
| 661 | selectedProjectDraftKey, | ||
| 662 | ], | ||
| 663 | ); | ||
| 664 | |||
| 665 | const finishEditingPendingTask = useCallback(() => { | ||
| 666 | const editing = editingPendingTaskRef.current; | ||
| 667 | editingPendingTaskRef.current = null; | ||
| 668 | if (editing) { | ||
| 669 | if (activeEditingMessageId === editing.messageId) { | ||
| 670 | activeEditingMessageId = null; | ||
| 671 | } | ||
| 672 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | ||
| 673 | releaseEditingQueuedMessage(editing.messageId); | ||
| 674 | } | ||
| 675 | setEditingPendingTask(null); | ||
| 676 | }, []); | ||
| 677 | |||
| 678 | // If the queued task disappears mid-edit (deleted from the list, or | ||
| 679 | // delivered), end the editing session immediately without saving — a later | ||
| 680 | // flush must not resurrect it, and the composer should fall back to the | ||
| 681 | // regular per-project draft. | ||
| 682 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); | ||
| 683 | useEffect(() => { | ||
| 684 | const editing = editingPendingTaskRef.current; | ||
| 685 | if (!editing) { | ||
| 686 | return; | ||
| 687 | } | ||
| 688 | const stillQueued = flattenQueuedThreadMessages(queuedMessagesByThreadKey).some( | ||
| 689 | (candidate) => candidate.messageId === editing.messageId, | ||
| 690 | ); | ||
| 691 | if (!stillQueued) { | ||
| 692 | finishEditingPendingTask(); | ||
| 693 | } | ||
| 694 | }, [finishEditingPendingTask, queuedMessagesByThreadKey]); | ||
| 695 | |||
| 696 | // Leaving the flow mid-edit (sheet dismissed or draft screen popped) saves | ||
| 697 | // the current edits back into the queued task so nothing typed here is lost. | ||
| 698 | const editingFlushRef = useRef<(() => void) | null>(null); | ||
| 699 | useEffect(() => { | ||
| 700 | editingFlushRef.current = () => { | ||
| 701 | const editing = editingPendingTaskRef.current; | ||
| 702 | if (!editing) { | ||
| 703 | return; | ||
| 704 | } | ||
| 705 | editingPendingTaskRef.current = null; | ||
| 706 | setEditingPendingTask(null); | ||
| 707 | if (activeEditingMessageId === editing.messageId) { | ||
| 708 | activeEditingMessageId = null; | ||
| 709 | } | ||
| 710 | |||
| 711 | const message = buildPendingTaskMessage({ | ||
| 712 | threadId: editing.threadId, | ||
| 713 | commandId: editing.commandId, | ||
| 714 | messageId: editing.messageId, | ||
| 715 | createdAt: editing.createdAt, | ||
| 716 | }); | ||
| 717 | |||
| 718 | if (!message) { | ||
| 719 | // The edits are currently unsendable (e.g. the prompt was cleared). | ||
| 720 | // Keep both the draft and the drain lock: the stale queued payload | ||
| 721 | // must not auto-send content the user just removed, and reopening the | ||
| 722 | // task resumes from the saved draft. | ||
| 723 | return; | ||
| 724 | } | ||
| 725 | |||
| 726 | // update() rewrites the task only if it is still queued — a concurrent | ||
| 727 | // delete or delivery wins, so the flush cannot resurrect it. | ||
| 728 | void updateThreadOutboxMessage(message) | ||
| 729 | .then(() => { | ||
| 730 | // If this task was reopened (possibly in a fresh provider) while | ||
| 731 | // the save was in flight, that session owns the draft and the lock. | ||
| 732 | if (activeEditingMessageId === editing.messageId) { | ||
| 733 | return; | ||
| 734 | } | ||
| 735 | clearComposerDraft(pendingTaskDraftKey(editing.messageId)); | ||
| 736 | releaseEditingQueuedMessage(editing.messageId); | ||
| 737 | }) | ||
| 738 | .catch((error) => { | ||
| 739 | // Keep the drain lock and the draft: delivering the stale payload | ||
| 740 | // would silently drop the newer edits. Reopening the task retries. | ||
| 741 | console.warn("[new-task] failed to save edited pending task", error); | ||
| 742 | }); | ||
| 743 | }; | ||
| 744 | }, [buildPendingTaskMessage]); | ||
| 745 | const cancelEditingPendingTask = useCallback(() => { | ||
| 746 | editingFlushRef.current?.(); | ||
| 747 | }, []); | ||
| 748 | useEffect( | ||
| 749 | () => () => { | ||
| 750 | editingFlushRef.current?.(); | ||
| 751 | }, | ||
| 752 | [], | ||
| 753 | ); | ||
| 754 | |||
| 463 | const value = useMemo<NewTaskFlowContextValue>( | 755 | const value = useMemo<NewTaskFlowContextValue>( |
| 464 | () => ({ | 756 | () => ({ |
| 465 | logicalProjects, | 757 | logicalProjects, |
| ... | @@ -469,6 +761,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -469,6 +761,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 469 | workspaceMode, | 761 | workspaceMode, |
| 470 | selectedBranchName, | 762 | selectedBranchName, |
| 471 | selectedWorktreePath, | 763 | selectedWorktreePath, |
| 764 | startFromOrigin, | ||
| 765 | draftKey: selectedProjectDraftKey, | ||
| 766 | editingPendingTask, | ||
| 472 | prompt, | 767 | prompt, |
| 473 | attachments, | 768 | attachments, |
| 474 | submitting, | 769 | submitting, |
| ... | @@ -492,6 +787,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -492,6 +787,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 492 | setSelectedModelKey, | 787 | setSelectedModelKey, |
| 493 | setWorkspaceMode, | 788 | setWorkspaceMode, |
| 494 | selectBranch, | 789 | selectBranch, |
| 790 | setStartFromOrigin, | ||
| 791 | beginEditingPendingTask, | ||
| 792 | finishEditingPendingTask, | ||
| 793 | cancelEditingPendingTask, | ||
| 794 | buildPendingTaskMessage, | ||
| 495 | setPrompt, | 795 | setPrompt, |
| 496 | replaceAttachments, | 796 | replaceAttachments, |
| 497 | appendAttachments, | 797 | appendAttachments, |
| ... | @@ -508,11 +808,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -508,11 +808,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 508 | [ | 808 | [ |
| 509 | attachments, | 809 | attachments, |
| 510 | availableBranches, | 810 | availableBranches, |
| 811 | beginEditingPendingTask, | ||
| 511 | branchQuery, | 812 | branchQuery, |
| 512 | branchesLoading, | 813 | branchesLoading, |
| 814 | buildPendingTaskMessage, | ||
| 815 | cancelEditingPendingTask, | ||
| 816 | editingPendingTask, | ||
| 513 | environments, | 817 | environments, |
| 514 | expandedProvider, | 818 | expandedProvider, |
| 515 | filteredBranches, | 819 | filteredBranches, |
| 820 | finishEditingPendingTask, | ||
| 516 | interactionMode, | 821 | interactionMode, |
| 517 | loadBranches, | 822 | loadBranches, |
| 518 | logicalProjects, | 823 | logicalProjects, |
| ... | @@ -527,6 +832,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -527,6 +832,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 527 | selectedModel, | 832 | selectedModel, |
| 528 | selectedModelKey, | 833 | selectedModelKey, |
| 529 | selectedModelOption, | 834 | selectedModelOption, |
| 835 | selectedProjectDraftKey, | ||
| 530 | selectedProviderSkills, | 836 | selectedProviderSkills, |
| 531 | setSelectedModelOptions, | 837 | setSelectedModelOptions, |
| 532 | selectedProject, | 838 | selectedProject, |
| ... | @@ -539,7 +845,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -539,7 +845,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 539 | setPrompt, | 845 | setPrompt, |
| 540 | setRuntimeMode, | 846 | setRuntimeMode, |
| 541 | setSelectedModelKey, | 847 | setSelectedModelKey, |
| 848 | setStartFromOrigin, | ||
| 542 | setWorkspaceMode, | 849 | setWorkspaceMode, |
| 850 | startFromOrigin, | ||
| 543 | submitting, | 851 | submitting, |
| 544 | workspaceMode, | 852 | workspaceMode, |
| 545 | appendAttachments, | 853 | appendAttachments, |
apps/mobile/src/features/threads/thread-list-items.tsx+168| ... | @@ -14,6 +14,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; | ... | @@ -14,6 +14,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; |
| 14 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 14 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 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 type { HomeGroupDisplayAction } from "../home/homeListItems"; | 19 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; |
| 19 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 20 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| ... | @@ -180,6 +181,173 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: | ... | @@ -180,6 +181,173 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: |
| 180 | ); | 181 | ); |
| 181 | }); | 182 | }); |
| 182 | 183 | ||
| 184 | /* ─── Pending task row ───────────────────────────────────────────────── */ | ||
| 185 | |||
| 186 | const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ | ||
| 187 | { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, | ||
| 188 | ]; | ||
| 189 | |||
| 190 | /** | ||
| 191 | * A queued new task waiting in the outbox for its environment to reconnect. | ||
| 192 | * Tapping reopens the new-task composer with everything prefilled; the row | ||
| 193 | * disappears once the task is delivered and the real thread arrives. | ||
| 194 | */ | ||
| 195 | export const PendingTaskListRow = memo(function PendingTaskListRow(props: { | ||
| 196 | readonly variant: ThreadListVariant; | ||
| 197 | readonly pendingTask: PendingNewTask; | ||
| 198 | readonly environmentLabel: string | null; | ||
| 199 | readonly isLast: boolean; | ||
| 200 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | ||
| 201 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | ||
| 202 | }) { | ||
| 203 | const compact = props.variant === "compact"; | ||
| 204 | const separatorColor = useThemeColor("--color-separator"); | ||
| 205 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 206 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 207 | const mutedColor = useThemeColor("--color-foreground-muted"); | ||
| 208 | const pressedBackgroundColor = useThemeColor("--color-subtle"); | ||
| 209 | |||
| 210 | const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; | ||
| 211 | const timestamp = relativeTime(pendingTask.message.createdAt); | ||
| 212 | const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter( | ||
| 213 | (part): part is string => Boolean(part), | ||
| 214 | ); | ||
| 215 | |||
| 216 | const handleMenuAction = useCallback( | ||
| 217 | ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { | ||
| 218 | if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); | ||
| 219 | }, | ||
| 220 | [onDeletePendingTask, pendingTask], | ||
| 221 | ); | ||
| 222 | |||
| 223 | const statusPill = ( | ||
| 224 | <View | ||
| 225 | className="bg-zinc-500/12 dark:bg-zinc-500/16" | ||
| 226 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | ||
| 227 | > | ||
| 228 | <Text className="text-3xs font-t3-bold text-zinc-600 dark:text-zinc-300">Pending</Text> | ||
| 229 | </View> | ||
| 230 | ); | ||
| 231 | |||
| 232 | const subtitleRow = | ||
| 233 | subtitleParts.length > 0 ? ( | ||
| 234 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | ||
| 235 | <SymbolView | ||
| 236 | name="tray.and.arrow.up" | ||
| 237 | size={10} | ||
| 238 | tintColor={compact ? iconSubtleColor : mutedColor} | ||
| 239 | type="monochrome" | ||
| 240 | /> | ||
| 241 | <Text | ||
| 242 | className={compact ? "text-sm text-foreground-muted" : "text-xs"} | ||
| 243 | numberOfLines={1} | ||
| 244 | style={compact ? { flexShrink: 1 } : { flexShrink: 1, color: mutedColor }} | ||
| 245 | > | ||
| 246 | {subtitleParts.join(" · ")} | ||
| 247 | </Text> | ||
| 248 | </View> | ||
| 249 | ) : null; | ||
| 250 | |||
| 251 | const rowContent = compact ? ( | ||
| 252 | <Pressable | ||
| 253 | accessibilityHint="Opens the queued task for editing" | ||
| 254 | accessibilityLabel={pendingTask.title} | ||
| 255 | accessibilityRole="button" | ||
| 256 | className="bg-screen" | ||
| 257 | onPress={() => onSelectPendingTask(pendingTask)} | ||
| 258 | style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} | ||
| 259 | > | ||
| 260 | <View | ||
| 261 | style={{ | ||
| 262 | paddingLeft: THREAD_LIST_COMPACT_INSET, | ||
| 263 | paddingRight: 18, | ||
| 264 | paddingTop: 10, | ||
| 265 | }} | ||
| 266 | > | ||
| 267 | <View | ||
| 268 | style={{ | ||
| 269 | gap: 3, | ||
| 270 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 271 | borderBottomColor: separatorColor, | ||
| 272 | paddingBottom: 10, | ||
| 273 | }} | ||
| 274 | > | ||
| 275 | <View className="flex-row items-center justify-between gap-2"> | ||
| 276 | <Text className="flex-1 text-lg font-t3-bold text-foreground" numberOfLines={1}> | ||
| 277 | {pendingTask.title} | ||
| 278 | </Text> | ||
| 279 | <View className="flex-row items-center gap-2"> | ||
| 280 | {statusPill} | ||
| 281 | <Text | ||
| 282 | className="text-base text-foreground-tertiary" | ||
| 283 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 284 | > | ||
| 285 | {timestamp} | ||
| 286 | </Text> | ||
| 287 | <SymbolView | ||
| 288 | name="chevron.right" | ||
| 289 | size={13} | ||
| 290 | tintColor={iconSubtleColor} | ||
| 291 | type="monochrome" | ||
| 292 | /> | ||
| 293 | </View> | ||
| 294 | </View> | ||
| 295 | {subtitleRow} | ||
| 296 | </View> | ||
| 297 | </View> | ||
| 298 | </Pressable> | ||
| 299 | ) : ( | ||
| 300 | <Pressable | ||
| 301 | accessibilityHint="Opens the queued task for editing" | ||
| 302 | accessibilityLabel={pendingTask.title} | ||
| 303 | accessibilityRole="button" | ||
| 304 | onPress={() => onSelectPendingTask(pendingTask)} | ||
| 305 | style={({ pressed }) => ({ | ||
| 306 | backgroundColor: pressed ? pressedBackgroundColor : "transparent", | ||
| 307 | borderRadius: SIDEBAR_ROW_RADIUS, | ||
| 308 | cursor: "pointer", | ||
| 309 | minHeight: 64, | ||
| 310 | justifyContent: "center", | ||
| 311 | paddingHorizontal: 12, | ||
| 312 | paddingVertical: 10, | ||
| 313 | })} | ||
| 314 | > | ||
| 315 | <View style={{ gap: 3 }}> | ||
| 316 | <View className="flex-row items-center justify-between gap-2"> | ||
| 317 | <Text | ||
| 318 | className="flex-1 text-base font-t3-medium" | ||
| 319 | numberOfLines={1} | ||
| 320 | style={{ color: foregroundColor }} | ||
| 321 | > | ||
| 322 | {pendingTask.title} | ||
| 323 | </Text> | ||
| 324 | <View className="flex-row items-center gap-2"> | ||
| 325 | {statusPill} | ||
| 326 | <Text | ||
| 327 | className="text-xs" | ||
| 328 | numberOfLines={1} | ||
| 329 | style={{ color: mutedColor, fontVariant: ["tabular-nums"] }} | ||
| 330 | > | ||
| 331 | {timestamp} | ||
| 332 | </Text> | ||
| 333 | </View> | ||
| 334 | </View> | ||
| 335 | {subtitleRow} | ||
| 336 | </View> | ||
| 337 | </Pressable> | ||
| 338 | ); | ||
| 339 | |||
| 340 | return ( | ||
| 341 | <ControlPillMenu | ||
| 342 | actions={PENDING_TASK_MENU_ACTIONS} | ||
| 343 | onPressAction={handleMenuAction} | ||
| 344 | shouldOpenOnLongPress | ||
| 345 | > | ||
| 346 | {rowContent} | ||
| 347 | </ControlPillMenu> | ||
| 348 | ); | ||
| 349 | }); | ||
| 350 | |||
| 183 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | 351 | /* ─── Thread row ─────────────────────────────────────────────────────── */ |
| 184 | 352 | ||
| 185 | const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ | 353 | const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ |
apps/mobile/src/features/threads/use-project-actions.ts+22-51| ... | @@ -4,8 +4,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ... | @@ -4,8 +4,6 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 4 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 5 | import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | 5 | import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import { | 6 | import { |
| 7 | CommandId, | ||
| 8 | MessageId, | ||
| 9 | ThreadId, | 7 | ThreadId, |
| 10 | type ModelSelection, | 8 | type ModelSelection, |
| 11 | type ProviderInteractionMode, | 9 | type ProviderInteractionMode, |
| ... | @@ -17,22 +15,13 @@ import { AsyncResult } from "effect/unstable/reactivity"; | ... | @@ -17,22 +15,13 @@ import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | 15 | ||
| 18 | import { threadEnvironment } from "../../state/threads"; | 16 | import { threadEnvironment } from "../../state/threads"; |
| 19 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 17 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 20 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | 18 | import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; |
| 21 | import { uuidv4 } from "../../lib/uuid"; | 19 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; |
| 20 | import { randomHex } from "../../lib/uuid"; | ||
| 22 | import { useAtomCommand } from "../../state/use-atom-command"; | 21 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 23 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; | 22 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; |
| 24 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; | 23 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; |
| 25 | 24 | ||
| 26 | function deriveThreadTitleFromPrompt(value: string): string { | ||
| 27 | const trimmed = value.trim(); | ||
| 28 | if (trimmed.length === 0) { | ||
| 29 | return "New thread"; | ||
| 30 | } | ||
| 31 | |||
| 32 | const compact = trimmed.replace(/\s+/g, " "); | ||
| 33 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function useCreateProjectThread() { | 25 | export function useCreateProjectThread() { |
| 37 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); | 26 | const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); |
| 38 | 27 | ||
| ... | @@ -43,15 +32,17 @@ export function useCreateProjectThread() { | ... | @@ -43,15 +32,17 @@ export function useCreateProjectThread() { |
| 43 | readonly envMode: "local" | "worktree"; | 32 | readonly envMode: "local" | "worktree"; |
| 44 | readonly branch: string | null; | 33 | readonly branch: string | null; |
| 45 | readonly worktreePath: string | null; | 34 | readonly worktreePath: string | null; |
| 35 | readonly startFromOrigin?: boolean; | ||
| 46 | readonly runtimeMode: RuntimeMode; | 36 | readonly runtimeMode: RuntimeMode; |
| 47 | readonly interactionMode: ProviderInteractionMode; | 37 | readonly interactionMode: ProviderInteractionMode; |
| 48 | readonly initialMessageText: string; | 38 | readonly initialMessageText: string; |
| 49 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | 39 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; |
| 40 | /** Reuse identifiers from a queued pending task instead of minting new ones. */ | ||
| 41 | readonly turnMetadata?: TurnCommandMetadata; | ||
| 50 | }) => { | 42 | }) => { |
| 51 | const metadata = makeTurnCommandMetadata(); | 43 | const metadata = input.turnMetadata ?? makeTurnCommandMetadata(); |
| 52 | const threadId = ThreadId.make(metadata.threadId); | 44 | const threadId = ThreadId.make(metadata.threadId); |
| 53 | const initialMessageText = input.initialMessageText.trim(); | 45 | const initialMessageText = input.initialMessageText.trim(); |
| 54 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | ||
| 55 | 46 | ||
| 56 | const validationError = validateProjectThreadCreation({ | 47 | const validationError = validateProjectThreadCreation({ |
| 57 | environmentId: input.project.environmentId, | 48 | environmentId: input.project.environmentId, |
| ... | @@ -65,46 +56,26 @@ export function useCreateProjectThread() { | ... | @@ -65,46 +56,26 @@ export function useCreateProjectThread() { |
| 65 | return AsyncResult.failure(Cause.fail(validationError)); | 56 | return AsyncResult.failure(Cause.fail(validationError)); |
| 66 | } | 57 | } |
| 67 | 58 | ||
| 68 | const isWorktree = input.envMode === "worktree"; | ||
| 69 | const result = await startTurn({ | 59 | const result = await startTurn({ |
| 70 | environmentId: input.project.environmentId, | 60 | environmentId: input.project.environmentId, |
| 71 | input: { | 61 | input: buildProjectThreadStartTurnInput({ |
| 72 | commandId: CommandId.make(metadata.commandId), | 62 | projectId: input.project.id, |
| 73 | threadId, | 63 | projectCwd: input.project.workspaceRoot, |
| 74 | message: { | 64 | threadId: metadata.threadId, |
| 75 | messageId: MessageId.make(metadata.messageId), | 65 | commandId: metadata.commandId, |
| 76 | role: "user", | 66 | messageId: metadata.messageId, |
| 77 | text: initialMessageText, | 67 | createdAt: metadata.createdAt, |
| 78 | attachments: input.initialAttachments, | 68 | text: initialMessageText, |
| 79 | }, | 69 | attachments: input.initialAttachments, |
| 80 | modelSelection: input.modelSelection, | 70 | modelSelection: input.modelSelection, |
| 81 | titleSeed: nextTitle, | ||
| 82 | runtimeMode: input.runtimeMode, | 71 | runtimeMode: input.runtimeMode, |
| 83 | interactionMode: input.interactionMode, | 72 | interactionMode: input.interactionMode, |
| 84 | bootstrap: { | 73 | workspaceMode: input.envMode, |
| 85 | createThread: { | 74 | branch: input.branch, |
| 86 | projectId: input.project.id, | 75 | worktreePath: input.worktreePath, |
| 87 | title: nextTitle, | 76 | startFromOrigin: input.startFromOrigin ?? false, |
| 88 | modelSelection: input.modelSelection, | 77 | worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), |
| 89 | runtimeMode: input.runtimeMode, | 78 | }), |
| 90 | interactionMode: input.interactionMode, | ||
| 91 | branch: input.branch, | ||
| 92 | worktreePath: isWorktree ? null : input.worktreePath, | ||
| 93 | createdAt: metadata.createdAt, | ||
| 94 | }, | ||
| 95 | ...(isWorktree | ||
| 96 | ? { | ||
| 97 | prepareWorktree: { | ||
| 98 | projectCwd: input.project.workspaceRoot, | ||
| 99 | baseBranch: input.branch!, | ||
| 100 | branch: buildTemporaryWorktreeBranchName(uuidv4), | ||
| 101 | }, | ||
| 102 | runSetupScript: true, | ||
| 103 | } | ||
| 104 | : {}), | ||
| 105 | }, | ||
| 106 | createdAt: metadata.createdAt, | ||
| 107 | }, | ||
| 108 | }); | 79 | }); |
| 109 | if (AsyncResult.isFailure(result)) { | 80 | if (AsyncResult.isFailure(result)) { |
| 110 | const error = Cause.squash(result.cause); | 81 | const error = Cause.squash(result.cause); |
apps/mobile/src/lib/projectThreadStartTurn.ts created+89| ... | @@ -0,0 +1,89 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | MessageId, | ||
| 4 | ThreadId, | ||
| 5 | type ModelSelection, | ||
| 6 | type ProjectId, | ||
| 7 | type ProviderInteractionMode, | ||
| 8 | type RuntimeMode, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | import type { DraftComposerImageAttachment } from "./composerImages"; | ||
| 12 | |||
| 13 | export function deriveThreadTitleFromPrompt(value: string): string { | ||
| 14 | const trimmed = value.trim(); | ||
| 15 | if (trimmed.length === 0) { | ||
| 16 | return "New thread"; | ||
| 17 | } | ||
| 18 | |||
| 19 | const compact = trimmed.replace(/\s+/g, " "); | ||
| 20 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface ProjectThreadStartTurnSpec { | ||
| 24 | readonly projectId: ProjectId; | ||
| 25 | readonly projectCwd: string; | ||
| 26 | readonly threadId: string; | ||
| 27 | readonly commandId: string; | ||
| 28 | readonly messageId: string; | ||
| 29 | readonly createdAt: string; | ||
| 30 | readonly text: string; | ||
| 31 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 32 | readonly modelSelection: ModelSelection; | ||
| 33 | readonly runtimeMode: RuntimeMode; | ||
| 34 | readonly interactionMode: ProviderInteractionMode; | ||
| 35 | readonly workspaceMode: "local" | "worktree"; | ||
| 36 | readonly branch: string | null; | ||
| 37 | readonly worktreePath: string | null; | ||
| 38 | readonly startFromOrigin: boolean; | ||
| 39 | /** Generated temp branch for worktree mode; unused for local mode. */ | ||
| 40 | readonly worktreeBranchName: string; | ||
| 41 | } | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Single source of the `thread.turn.start` bootstrap payload used to create a | ||
| 45 | * thread from a project draft — shared by the immediate send path and the | ||
| 46 | * offline outbox drain so both deliver identical commands. | ||
| 47 | */ | ||
| 48 | export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) { | ||
| 49 | const title = deriveThreadTitleFromPrompt(spec.text); | ||
| 50 | const isWorktree = spec.workspaceMode === "worktree"; | ||
| 51 | return { | ||
| 52 | commandId: CommandId.make(spec.commandId), | ||
| 53 | threadId: ThreadId.make(spec.threadId), | ||
| 54 | message: { | ||
| 55 | messageId: MessageId.make(spec.messageId), | ||
| 56 | role: "user" as const, | ||
| 57 | text: spec.text, | ||
| 58 | attachments: spec.attachments, | ||
| 59 | }, | ||
| 60 | modelSelection: spec.modelSelection, | ||
| 61 | titleSeed: title, | ||
| 62 | runtimeMode: spec.runtimeMode, | ||
| 63 | interactionMode: spec.interactionMode, | ||
| 64 | bootstrap: { | ||
| 65 | createThread: { | ||
| 66 | projectId: spec.projectId, | ||
| 67 | title, | ||
| 68 | modelSelection: spec.modelSelection, | ||
| 69 | runtimeMode: spec.runtimeMode, | ||
| 70 | interactionMode: spec.interactionMode, | ||
| 71 | branch: spec.branch, | ||
| 72 | worktreePath: isWorktree ? null : spec.worktreePath, | ||
| 73 | createdAt: spec.createdAt, | ||
| 74 | }, | ||
| 75 | ...(isWorktree | ||
| 76 | ? { | ||
| 77 | prepareWorktree: { | ||
| 78 | projectCwd: spec.projectCwd, | ||
| 79 | baseBranch: spec.branch!, | ||
| 80 | branch: spec.worktreeBranchName, | ||
| 81 | ...(spec.startFromOrigin ? { startFromOrigin: true } : {}), | ||
| 82 | }, | ||
| 83 | runSetupScript: true, | ||
| 84 | } | ||
| 85 | : {}), | ||
| 86 | }, | ||
| 87 | createdAt: spec.createdAt, | ||
| 88 | }; | ||
| 89 | } | ||
apps/mobile/src/lib/uuid.ts+6| ... | @@ -1,3 +1,9 @@ | ... | @@ -1,3 +1,9 @@ |
| 1 | import * as Crypto from "expo-crypto"; | 1 | import * as Crypto from "expo-crypto"; |
| 2 | 2 | ||
| 3 | export const uuidv4 = () => Crypto.randomUUID(); | 3 | export const uuidv4 = () => Crypto.randomUUID(); |
| 4 | |||
| 5 | /** Random lowercase hex string of `byteLength` bytes (2 chars per byte). */ | ||
| 6 | export const randomHex = (byteLength: number): string => | ||
| 7 | Array.from(Crypto.getRandomBytes(byteLength), (byte) => byte.toString(16).padStart(2, "0")).join( | ||
| 8 | "", | ||
| 9 | ); |
apps/mobile/src/state/thread-outbox-manager.ts+31| ... | @@ -15,6 +15,7 @@ export class ThreadOutboxManagerError extends Schema.TaggedErrorClass<ThreadOutb | ... | @@ -15,6 +15,7 @@ export class ThreadOutboxManagerError extends Schema.TaggedErrorClass<ThreadOutb |
| 15 | operation: Schema.Literals([ | 15 | operation: Schema.Literals([ |
| 16 | "load", | 16 | "load", |
| 17 | "enqueue", | 17 | "enqueue", |
| 18 | "update", | ||
| 18 | "remove", | 19 | "remove", |
| 19 | "clear-environment-load", | 20 | "clear-environment-load", |
| 20 | "clear-environment-remove", | 21 | "clear-environment-remove", |
| ... | @@ -103,6 +104,35 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -103,6 +104,35 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 103 | setMessages([...currentMessages(), message]); | 104 | setMessages([...currentMessages(), message]); |
| 104 | }); | 105 | }); |
| 105 | 106 | ||
| 107 | // Rewrites an already-queued message. A no-op when the message has been | ||
| 108 | // removed in the meantime (e.g. deleted or delivered), so a trailing editor | ||
| 109 | // flush can never resurrect it. Returns whether the message was updated. | ||
| 110 | const update = (message: QueuedThreadMessage): Promise<boolean> => | ||
| 111 | serialize(async () => { | ||
| 112 | const exists = currentMessages().some( | ||
| 113 | (candidate) => candidate.messageId === message.messageId, | ||
| 114 | ); | ||
| 115 | if (!exists) { | ||
| 116 | return false; | ||
| 117 | } | ||
| 118 | try { | ||
| 119 | await options.storage.write(message); | ||
| 120 | } catch (cause) { | ||
| 121 | throw new ThreadOutboxManagerError({ | ||
| 122 | operation: "update", | ||
| 123 | environmentId: message.environmentId, | ||
| 124 | threadId: message.threadId, | ||
| 125 | messageId: message.messageId, | ||
| 126 | cause, | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | setMessages([ | ||
| 130 | ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), | ||
| 131 | message, | ||
| 132 | ]); | ||
| 133 | return true; | ||
| 134 | }); | ||
| 135 | |||
| 106 | const remove = (message: QueuedThreadMessage): Promise<void> => | 136 | const remove = (message: QueuedThreadMessage): Promise<void> => |
| 107 | serialize(async () => { | 137 | serialize(async () => { |
| 108 | try { | 138 | try { |
| ... | @@ -171,6 +201,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -171,6 +201,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 171 | serialize, | 201 | serialize, |
| 172 | load, | 202 | load, |
| 173 | enqueue, | 203 | enqueue, |
| 204 | update, | ||
| 174 | remove, | 205 | remove, |
| 175 | clearEnvironment, | 206 | clearEnvironment, |
| 176 | }; | 207 | }; |
apps/mobile/src/state/thread-outbox-model.ts+56-2| ... | @@ -6,10 +6,12 @@ import { | ... | @@ -6,10 +6,12 @@ import { |
| 6 | IsoDateTime, | 6 | IsoDateTime, |
| 7 | MessageId, | 7 | MessageId, |
| 8 | ModelSelection, | 8 | ModelSelection, |
| 9 | ProjectId, | ||
| 9 | ProviderInteractionMode, | 10 | ProviderInteractionMode, |
| 10 | RuntimeMode, | 11 | RuntimeMode, |
| 11 | ThreadId, | 12 | ThreadId, |
| 12 | type ModelSelection as ModelSelectionType, | 13 | type ModelSelection as ModelSelectionType, |
| 14 | type ProjectId as ProjectIdType, | ||
| 13 | type ProviderInteractionMode as ProviderInteractionModeType, | 15 | type ProviderInteractionMode as ProviderInteractionModeType, |
| 14 | type RuntimeMode as RuntimeModeType, | 16 | type RuntimeMode as RuntimeModeType, |
| 15 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| ... | @@ -19,11 +21,23 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema | ... | @@ -19,11 +21,23 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema |
| 19 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 21 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 20 | import { scopedThreadKey } from "../lib/scopedEntities"; | 22 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 21 | 23 | ||
| 22 | const THREAD_OUTBOX_SCHEMA_VERSION = 2; | 24 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
| 23 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; | 25 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; |
| 24 | 26 | ||
| 27 | const QueuedThreadCreationSchema = Schema.Struct({ | ||
| 28 | projectId: ProjectId, | ||
| 29 | // Snapshot of the project's display metadata so a pending task stays | ||
| 30 | // presentable in the thread list even when the project shell is not loaded. | ||
| 31 | projectTitle: Schema.optional(Schema.String), | ||
| 32 | projectCwd: Schema.optional(Schema.String), | ||
| 33 | workspaceMode: Schema.Literals(["local", "worktree"]), | ||
| 34 | branch: Schema.NullOr(Schema.String), | ||
| 35 | worktreePath: Schema.NullOr(Schema.String), | ||
| 36 | startFromOrigin: Schema.optional(Schema.Boolean), | ||
| 37 | }); | ||
| 38 | |||
| 25 | export const QueuedThreadMessageSchema = Schema.Struct({ | 39 | export const QueuedThreadMessageSchema = Schema.Struct({ |
| 26 | schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), | 40 | schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), |
| 27 | environmentId: EnvironmentId, | 41 | environmentId: EnvironmentId, |
| 28 | threadId: ThreadId, | 42 | threadId: ThreadId, |
| 29 | messageId: MessageId, | 43 | messageId: MessageId, |
| ... | @@ -33,12 +47,25 @@ export const QueuedThreadMessageSchema = Schema.Struct({ | ... | @@ -33,12 +47,25 @@ export const QueuedThreadMessageSchema = Schema.Struct({ |
| 33 | modelSelection: Schema.optional(ModelSelection), | 47 | modelSelection: Schema.optional(ModelSelection), |
| 34 | runtimeMode: Schema.optional(RuntimeMode), | 48 | runtimeMode: Schema.optional(RuntimeMode), |
| 35 | interactionMode: Schema.optional(ProviderInteractionMode), | 49 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 50 | // Present when the queued item creates a brand-new thread (pending task) | ||
| 51 | // instead of appending a turn to an existing one. | ||
| 52 | creation: Schema.optional(QueuedThreadCreationSchema), | ||
| 36 | createdAt: IsoDateTime, | 53 | createdAt: IsoDateTime, |
| 37 | }); | 54 | }); |
| 38 | 55 | ||
| 39 | const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); | 56 | const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); |
| 40 | const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); | 57 | const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); |
| 41 | 58 | ||
| 59 | export interface QueuedThreadCreation { | ||
| 60 | readonly projectId: ProjectIdType; | ||
| 61 | readonly projectTitle?: string; | ||
| 62 | readonly projectCwd?: string; | ||
| 63 | readonly workspaceMode: "local" | "worktree"; | ||
| 64 | readonly branch: string | null; | ||
| 65 | readonly worktreePath: string | null; | ||
| 66 | readonly startFromOrigin?: boolean; | ||
| 67 | } | ||
| 68 | |||
| 42 | export interface QueuedThreadMessage { | 69 | export interface QueuedThreadMessage { |
| 43 | readonly environmentId: EnvironmentId; | 70 | readonly environmentId: EnvironmentId; |
| 44 | readonly threadId: ThreadId; | 71 | readonly threadId: ThreadId; |
| ... | @@ -49,6 +76,7 @@ export interface QueuedThreadMessage { | ... | @@ -49,6 +76,7 @@ export interface QueuedThreadMessage { |
| 49 | readonly modelSelection?: ModelSelectionType; | 76 | readonly modelSelection?: ModelSelectionType; |
| 50 | readonly runtimeMode?: RuntimeModeType; | 77 | readonly runtimeMode?: RuntimeModeType; |
| 51 | readonly interactionMode?: ProviderInteractionModeType; | 78 | readonly interactionMode?: ProviderInteractionModeType; |
| 79 | readonly creation?: QueuedThreadCreation; | ||
| 52 | readonly createdAt: string; | 80 | readonly createdAt: string; |
| 53 | } | 81 | } |
| 54 | 82 | ||
| ... | @@ -121,17 +149,43 @@ export function threadOutboxRetryDelayMs(attempt: number): number { | ... | @@ -121,17 +149,43 @@ export function threadOutboxRetryDelayMs(attempt: number): number { |
| 121 | export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; | 149 | export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; |
| 122 | 150 | ||
| 123 | export function resolveThreadOutboxDeliveryAction(input: { | 151 | export function resolveThreadOutboxDeliveryAction(input: { |
| 152 | readonly isCreation: boolean; | ||
| 124 | readonly threadExists: boolean; | 153 | readonly threadExists: boolean; |
| 125 | readonly shellStatus: EnvironmentShellStatus; | 154 | readonly shellStatus: EnvironmentShellStatus; |
| 126 | readonly environmentConnected: boolean; | 155 | readonly environmentConnected: boolean; |
| 127 | readonly threadBusy: boolean; | 156 | readonly threadBusy: boolean; |
| 128 | }): ThreadOutboxDeliveryAction { | 157 | }): ThreadOutboxDeliveryAction { |
| 158 | if (input.isCreation) { | ||
| 159 | // A pending task creates its thread on delivery. If the thread already | ||
| 160 | // exists the creation command went through and only cleanup remains. | ||
| 161 | if (input.threadExists) { | ||
| 162 | return "remove"; | ||
| 163 | } | ||
| 164 | // Wait for the shell to be live before sending: until the thread list has | ||
| 165 | // synchronized, a previously delivered creation whose cleanup failed would | ||
| 166 | // look missing and get re-issued, duplicating the thread. | ||
| 167 | return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait"; | ||
| 168 | } | ||
| 129 | if (!input.threadExists) { | 169 | if (!input.threadExists) { |
| 130 | return input.shellStatus === "live" ? "remove" : "wait"; | 170 | return input.shellStatus === "live" ? "remove" : "wait"; |
| 131 | } | 171 | } |
| 132 | return input.environmentConnected && !input.threadBusy ? "send" : "wait"; | 172 | return input.environmentConnected && !input.threadBusy ? "send" : "wait"; |
| 133 | } | 173 | } |
| 134 | 174 | ||
| 175 | /** | ||
| 176 | * A queued creation can only be dispatched once its payload would pass server | ||
| 177 | * validation; incomplete payloads stay pending until the user edits them. | ||
| 178 | */ | ||
| 179 | export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): boolean { | ||
| 180 | if (!message.creation) { | ||
| 181 | return false; | ||
| 182 | } | ||
| 183 | if (message.text.trim().length === 0 || message.modelSelection === undefined) { | ||
| 184 | return false; | ||
| 185 | } | ||
| 186 | return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); | ||
| 187 | } | ||
| 188 | |||
| 135 | function errorMessage(error: unknown): string | null { | 189 | function errorMessage(error: unknown): string | null { |
| 136 | if (error instanceof Error) { | 190 | if (error instanceof Error) { |
| 137 | return error.message; | 191 | return error.message; |
apps/mobile/src/state/thread-outbox.test.ts+121| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | CommandId, | 3 | CommandId, |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | MessageId, | 5 | MessageId, |
| 6 | ProjectId, | ||
| 6 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 7 | ThreadId, | 8 | ThreadId, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| ... | @@ -12,6 +13,7 @@ import { | ... | @@ -12,6 +13,7 @@ import { |
| 12 | decodeQueuedThreadMessage, | 13 | decodeQueuedThreadMessage, |
| 13 | encodeQueuedThreadMessage, | 14 | encodeQueuedThreadMessage, |
| 14 | groupQueuedThreadMessages, | 15 | groupQueuedThreadMessages, |
| 16 | isQueuedThreadCreationSendable, | ||
| 15 | modelSelectionsEqual, | 17 | modelSelectionsEqual, |
| 16 | resolveThreadOutboxDeliveryAction, | 18 | resolveThreadOutboxDeliveryAction, |
| 17 | resolveThreadOutboxFailureAction, | 19 | resolveThreadOutboxFailureAction, |
| ... | @@ -292,9 +294,43 @@ describe("thread outbox", () => { | ... | @@ -292,9 +294,43 @@ describe("thread outbox", () => { |
| 292 | registry.dispose(); | 294 | registry.dispose(); |
| 293 | }); | 295 | }); |
| 294 | 296 | ||
| 297 | it("updates a queued message in place but never resurrects a removed one", async () => { | ||
| 298 | const registry = AtomRegistry.make(); | ||
| 299 | const stored = new Map<MessageId, QueuedThreadMessage>(); | ||
| 300 | const storage: ThreadOutboxStorage = { | ||
| 301 | load: async () => [...stored.values()], | ||
| 302 | write: async (message) => { | ||
| 303 | stored.set(message.messageId, message); | ||
| 304 | }, | ||
| 305 | remove: async (message) => { | ||
| 306 | stored.delete(message.messageId); | ||
| 307 | }, | ||
| 308 | }; | ||
| 309 | const manager = createThreadOutboxManager({ registry, storage }); | ||
| 310 | const message = queuedMessage({ | ||
| 311 | messageId: "message-1", | ||
| 312 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 313 | }); | ||
| 314 | |||
| 315 | await manager.enqueue(message); | ||
| 316 | const edited = { ...message, text: "edited" }; | ||
| 317 | await expect(manager.update(edited)).resolves.toBe(true); | ||
| 318 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | ||
| 319 | "environment-1:thread-1": [edited], | ||
| 320 | }); | ||
| 321 | expect(stored.get(message.messageId)).toEqual(edited); | ||
| 322 | |||
| 323 | await manager.remove(edited); | ||
| 324 | await expect(manager.update({ ...message, text: "stale flush" })).resolves.toBe(false); | ||
| 325 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); | ||
| 326 | expect(stored.size).toBe(0); | ||
| 327 | registry.dispose(); | ||
| 328 | }); | ||
| 329 | |||
| 295 | it("only removes a missing-thread message after shell synchronization is live", () => { | 330 | it("only removes a missing-thread message after shell synchronization is live", () => { |
| 296 | expect( | 331 | expect( |
| 297 | resolveThreadOutboxDeliveryAction({ | 332 | resolveThreadOutboxDeliveryAction({ |
| 333 | isCreation: false, | ||
| 298 | threadExists: false, | 334 | threadExists: false, |
| 299 | shellStatus: "synchronizing", | 335 | shellStatus: "synchronizing", |
| 300 | environmentConnected: true, | 336 | environmentConnected: true, |
| ... | @@ -303,6 +339,7 @@ describe("thread outbox", () => { | ... | @@ -303,6 +339,7 @@ describe("thread outbox", () => { |
| 303 | ).toBe("wait"); | 339 | ).toBe("wait"); |
| 304 | expect( | 340 | expect( |
| 305 | resolveThreadOutboxDeliveryAction({ | 341 | resolveThreadOutboxDeliveryAction({ |
| 342 | isCreation: false, | ||
| 306 | threadExists: false, | 343 | threadExists: false, |
| 307 | shellStatus: "live", | 344 | shellStatus: "live", |
| 308 | environmentConnected: true, | 345 | environmentConnected: true, |
| ... | @@ -311,6 +348,7 @@ describe("thread outbox", () => { | ... | @@ -311,6 +348,7 @@ describe("thread outbox", () => { |
| 311 | ).toBe("remove"); | 348 | ).toBe("remove"); |
| 312 | expect( | 349 | expect( |
| 313 | resolveThreadOutboxDeliveryAction({ | 350 | resolveThreadOutboxDeliveryAction({ |
| 351 | isCreation: false, | ||
| 314 | threadExists: true, | 352 | threadExists: true, |
| 315 | shellStatus: "live", | 353 | shellStatus: "live", |
| 316 | environmentConnected: true, | 354 | environmentConnected: true, |
| ... | @@ -319,6 +357,89 @@ describe("thread outbox", () => { | ... | @@ -319,6 +357,89 @@ describe("thread outbox", () => { |
| 319 | ).toBe("send"); | 357 | ).toBe("send"); |
| 320 | }); | 358 | }); |
| 321 | 359 | ||
| 360 | it("sends queued creations once connected and live, removing already-created ones", () => { | ||
| 361 | expect( | ||
| 362 | resolveThreadOutboxDeliveryAction({ | ||
| 363 | isCreation: true, | ||
| 364 | threadExists: false, | ||
| 365 | shellStatus: "cached", | ||
| 366 | environmentConnected: false, | ||
| 367 | threadBusy: false, | ||
| 368 | }), | ||
| 369 | ).toBe("wait"); | ||
| 370 | // Connected but not yet synchronized: a previously delivered creation may | ||
| 371 | // simply not be visible yet — sending now could duplicate the thread. | ||
| 372 | expect( | ||
| 373 | resolveThreadOutboxDeliveryAction({ | ||
| 374 | isCreation: true, | ||
| 375 | threadExists: false, | ||
| 376 | shellStatus: "synchronizing", | ||
| 377 | environmentConnected: true, | ||
| 378 | threadBusy: false, | ||
| 379 | }), | ||
| 380 | ).toBe("wait"); | ||
| 381 | expect( | ||
| 382 | resolveThreadOutboxDeliveryAction({ | ||
| 383 | isCreation: true, | ||
| 384 | threadExists: false, | ||
| 385 | shellStatus: "live", | ||
| 386 | environmentConnected: true, | ||
| 387 | threadBusy: false, | ||
| 388 | }), | ||
| 389 | ).toBe("send"); | ||
| 390 | expect( | ||
| 391 | resolveThreadOutboxDeliveryAction({ | ||
| 392 | isCreation: true, | ||
| 393 | threadExists: true, | ||
| 394 | shellStatus: "live", | ||
| 395 | environmentConnected: true, | ||
| 396 | threadBusy: true, | ||
| 397 | }), | ||
| 398 | ).toBe("remove"); | ||
| 399 | }); | ||
| 400 | |||
| 401 | it("round-trips queued creations and gates incomplete ones from sending", () => { | ||
| 402 | const base = queuedMessage({ | ||
| 403 | messageId: "message-1", | ||
| 404 | createdAt: "2026-06-08T10:00:01.000Z", | ||
| 405 | }); | ||
| 406 | const creationMessage = { | ||
| 407 | ...base, | ||
| 408 | modelSelection: { | ||
| 409 | instanceId: ProviderInstanceId.make("codex"), | ||
| 410 | model: "gpt-5.4", | ||
| 411 | }, | ||
| 412 | creation: { | ||
| 413 | projectId: ProjectId.make("project-1"), | ||
| 414 | workspaceMode: "worktree", | ||
| 415 | branch: "main", | ||
| 416 | worktreePath: null, | ||
| 417 | startFromOrigin: true, | ||
| 418 | }, | ||
| 419 | } satisfies QueuedThreadMessage; | ||
| 420 | |||
| 421 | expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(creationMessage))).toEqual( | ||
| 422 | creationMessage, | ||
| 423 | ); | ||
| 424 | expect(isQueuedThreadCreationSendable(creationMessage)).toBe(true); | ||
| 425 | expect( | ||
| 426 | isQueuedThreadCreationSendable({ | ||
| 427 | ...creationMessage, | ||
| 428 | creation: { ...creationMessage.creation, branch: null }, | ||
| 429 | }), | ||
| 430 | ).toBe(false); | ||
| 431 | expect( | ||
| 432 | isQueuedThreadCreationSendable({ | ||
| 433 | ...creationMessage, | ||
| 434 | creation: { ...creationMessage.creation, branch: "" }, | ||
| 435 | }), | ||
| 436 | ).toBe(false); | ||
| 437 | expect(isQueuedThreadCreationSendable({ ...creationMessage, modelSelection: undefined })).toBe( | ||
| 438 | false, | ||
| 439 | ); | ||
| 440 | expect(isQueuedThreadCreationSendable(base)).toBe(false); | ||
| 441 | }); | ||
| 442 | |||
| 322 | it("retries transport failures but drops deterministic command failures", () => { | 443 | it("retries transport failures but drops deterministic command failures", () => { |
| 323 | expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); | 444 | expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); |
| 324 | expect( | 445 | expect( |
apps/mobile/src/state/thread-outbox.ts+5| ... | @@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis | ... | @@ -20,6 +20,11 @@ export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promis |
| 20 | return threadOutboxManager.enqueue(message); | 20 | return threadOutboxManager.enqueue(message); |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | /** Rewrite a queued message; no-op (false) if it was removed in the meantime. */ | ||
| 24 | export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise<boolean> { | ||
| 25 | return threadOutboxManager.update(message); | ||
| 26 | } | ||
| 27 | |||
| 23 | export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { | 28 | export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { |
| 24 | return threadOutboxManager.remove(message); | 29 | return threadOutboxManager.remove(message); |
| 25 | } | 30 | } |
apps/mobile/src/state/use-composer-drafts.ts+6| ... | @@ -48,6 +48,7 @@ export interface ComposerDraftWorkspaceSelection { | ... | @@ -48,6 +48,7 @@ export interface ComposerDraftWorkspaceSelection { |
| 48 | readonly mode: "local" | "worktree"; | 48 | readonly mode: "local" | "worktree"; |
| 49 | readonly branch: string | null; | 49 | readonly branch: string | null; |
| 50 | readonly worktreePath: string | null; | 50 | readonly worktreePath: string | null; |
| 51 | readonly startFromOrigin?: boolean; | ||
| 51 | } | 52 | } |
| 52 | 53 | ||
| 53 | export type ComposerDraftSettingsUpdate = Pick< | 54 | export type ComposerDraftSettingsUpdate = Pick< |
| ... | @@ -59,6 +60,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ | ... | @@ -59,6 +60,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ |
| 59 | mode: Schema.Literals(["local", "worktree"]), | 60 | mode: Schema.Literals(["local", "worktree"]), |
| 60 | branch: Schema.NullOr(Schema.String), | 61 | branch: Schema.NullOr(Schema.String), |
| 61 | worktreePath: Schema.NullOr(Schema.String), | 62 | worktreePath: Schema.NullOr(Schema.String), |
| 63 | startFromOrigin: Schema.optional(Schema.Boolean), | ||
| 62 | }); | 64 | }); |
| 63 | 65 | ||
| 64 | const ComposerDraftSchema = Schema.Struct({ | 66 | const ComposerDraftSchema = Schema.Struct({ |
| ... | @@ -107,6 +109,10 @@ export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { | ... | @@ -107,6 +109,10 @@ export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { |
| 107 | return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); | 109 | return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); |
| 108 | } | 110 | } |
| 109 | 111 | ||
| 112 | export function isComposerDraftEmpty(draft: ComposerDraft): boolean { | ||
| 113 | return isEmptyDraft(draft); | ||
| 114 | } | ||
| 115 | |||
| 110 | function isEmptyDraft(draft: ComposerDraft): boolean { | 116 | function isEmptyDraft(draft: ComposerDraft): boolean { |
| 111 | return ( | 117 | return ( |
| 112 | draft.text.length === 0 && | 118 | draft.text.length === 0 && |
apps/mobile/src/state/use-pending-new-tasks.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; | ||
| 4 | import { | ||
| 5 | flattenQueuedThreadMessages, | ||
| 6 | type QueuedThreadCreation, | ||
| 7 | type QueuedThreadMessage, | ||
| 8 | } from "./thread-outbox-model"; | ||
| 9 | import { useThreadOutboxMessages } from "./use-thread-outbox"; | ||
| 10 | |||
| 11 | /** A queued new-task creation, shaped for thread-list presentation. */ | ||
| 12 | export interface PendingNewTask { | ||
| 13 | readonly message: QueuedThreadMessage; | ||
| 14 | readonly creation: QueuedThreadCreation; | ||
| 15 | readonly title: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> { | ||
| 19 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); | ||
| 20 | return useMemo(() => { | ||
| 21 | const tasks: PendingNewTask[] = []; | ||
| 22 | for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) { | ||
| 23 | if (!message.creation) { | ||
| 24 | continue; | ||
| 25 | } | ||
| 26 | tasks.push({ | ||
| 27 | message, | ||
| 28 | creation: message.creation, | ||
| 29 | title: deriveThreadTitleFromPrompt(message.text), | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt)); | ||
| 33 | return tasks; | ||
| 34 | }, [queuedMessagesByThreadKey]); | ||
| 35 | } | ||
apps/mobile/src/state/use-thread-outbox-drain.ts+174-62| ... | @@ -1,27 +1,44 @@ | ... | @@ -1,27 +1,44 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 2 | import type { |
| 3 | EnvironmentProject, | ||
| 4 | EnvironmentThreadShell, | ||
| 5 | } from "@t3tools/client-runtime/state/shell"; | ||
| 3 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | 6 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import { CommandId, type MessageId } from "@t3tools/contracts"; | 7 | import { |
| 8 | CommandId, | ||
| 9 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 10 | DEFAULT_RUNTIME_MODE, | ||
| 11 | type MessageId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | ||
| 5 | import * as Cause from "effect/Cause"; | 14 | import * as Cause from "effect/Cause"; |
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | import { useCallback, useEffect, useRef, useState } from "react"; | 16 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 8 | 17 | ||
| 9 | import { scopedThreadKey } from "../lib/scopedEntities"; | 18 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 19 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; | ||
| 20 | import { randomHex } from "../lib/uuid"; | ||
| 10 | import { appAtomRegistry } from "./atom-registry"; | 21 | import { appAtomRegistry } from "./atom-registry"; |
| 11 | import { useThreadShells } from "./entities"; | 22 | import { useProjects, useThreadShells } from "./entities"; |
| 12 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; | 23 | import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; |
| 13 | import { | 24 | import { |
| 25 | isQueuedThreadCreationSendable, | ||
| 14 | modelSelectionsEqual, | 26 | modelSelectionsEqual, |
| 15 | resolveThreadOutboxDeliveryAction, | 27 | resolveThreadOutboxDeliveryAction, |
| 16 | resolveThreadOutboxFailureAction, | 28 | resolveThreadOutboxFailureAction, |
| 17 | resolveQueuedThreadSettings, | 29 | resolveQueuedThreadSettings, |
| 18 | threadOutboxRetryDelayMs, | 30 | threadOutboxRetryDelayMs, |
| 31 | type QueuedThreadCreation, | ||
| 19 | type QueuedThreadMessage, | 32 | type QueuedThreadMessage, |
| 20 | type ThreadOutboxCommandStage, | 33 | type ThreadOutboxCommandStage, |
| 21 | } from "./thread-outbox-model"; | 34 | } from "./thread-outbox-model"; |
| 22 | import { threadEnvironment } from "./threads"; | 35 | import { threadEnvironment } from "./threads"; |
| 23 | import { useAtomCommand } from "./use-atom-command"; | 36 | import { useAtomCommand } from "./use-atom-command"; |
| 24 | import { useThreadOutboxMessages, useThreadOutboxShellStatuses } from "./use-thread-outbox"; | 37 | import { |
| 38 | editingQueuedMessageIdsAtom, | ||
| 39 | useThreadOutboxMessages, | ||
| 40 | useThreadOutboxShellStatuses, | ||
| 41 | } from "./use-thread-outbox"; | ||
| 25 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; | 42 | import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; |
| 26 | 43 | ||
| 27 | export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | 44 | export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( |
| ... | @@ -48,6 +65,17 @@ function findThread( | ... | @@ -48,6 +65,17 @@ function findThread( |
| 48 | ); | 65 | ); |
| 49 | } | 66 | } |
| 50 | 67 | ||
| 68 | function findCreationProject( | ||
| 69 | projects: ReadonlyArray<EnvironmentProject>, | ||
| 70 | message: QueuedThreadMessage, | ||
| 71 | ): EnvironmentProject | undefined { | ||
| 72 | return projects.find( | ||
| 73 | (candidate) => | ||
| 74 | candidate.environmentId === message.environmentId && | ||
| 75 | candidate.id === message.creation?.projectId, | ||
| 76 | ); | ||
| 77 | } | ||
| 78 | |||
| 51 | function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { | 79 | function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { |
| 52 | return CommandId.make(`${message.commandId}:${setting}`); | 80 | return CommandId.make(`${message.commandId}:${setting}`); |
| 53 | } | 81 | } |
| ... | @@ -64,9 +92,11 @@ export function useThreadOutboxDrain(): void { | ... | @@ -64,9 +92,11 @@ export function useThreadOutboxDrain(): void { |
| 64 | reportFailure: false, | 92 | reportFailure: false, |
| 65 | }); | 93 | }); |
| 66 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | 94 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); |
| 95 | const editingQueuedMessageIds = useAtomValue(editingQueuedMessageIdsAtom); | ||
| 67 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); | 96 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); |
| 68 | const shellStatuses = useThreadOutboxShellStatuses(); | 97 | const shellStatuses = useThreadOutboxShellStatuses(); |
| 69 | const threads = useThreadShells(); | 98 | const threads = useThreadShells(); |
| 99 | const projects = useProjects(); | ||
| 70 | const { connectedEnvironments } = useRemoteConnectionStatus(); | 100 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 71 | const [retryTick, setRetryTick] = useState(0); | 101 | const [retryTick, setRetryTick] = useState(0); |
| 72 | const retryAttemptRef = useRef(new Map<MessageId, number>()); | 102 | const retryAttemptRef = useRef(new Map<MessageId, number>()); |
| ... | @@ -83,52 +113,57 @@ export function useThreadOutboxDrain(): void { | ... | @@ -83,52 +113,57 @@ export function useThreadOutboxDrain(): void { |
| 83 | }; | 113 | }; |
| 84 | }, []); | 114 | }, []); |
| 85 | 115 | ||
| 86 | const sendQueuedMessage = useCallback( | 116 | const makeDeliveryHelpers = useCallback((queuedMessage: QueuedThreadMessage) => { |
| 87 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { | 117 | const reportFailure = ( |
| 88 | const settings = resolveQueuedThreadSettings(queuedMessage, thread); | 118 | commandResult: AtomCommandResult<unknown, unknown>, |
| 89 | const reportFailure = ( | 119 | stage: ThreadOutboxCommandStage, |
| 90 | commandResult: AtomCommandResult<unknown, unknown>, | 120 | ): boolean => { |
| 91 | stage: ThreadOutboxCommandStage, | 121 | if (!AsyncResult.isFailure(commandResult)) { |
| 92 | ): boolean => { | 122 | return false; |
| 93 | if (!AsyncResult.isFailure(commandResult)) { | 123 | } |
| 94 | return false; | 124 | const action = resolveThreadOutboxFailureAction({ |
| 95 | } | 125 | stage, |
| 96 | const action = resolveThreadOutboxFailureAction({ | 126 | error: Cause.squash(commandResult.cause), |
| 97 | stage, | 127 | interrupted: Cause.hasInterruptsOnly(commandResult.cause), |
| 98 | error: Cause.squash(commandResult.cause), | 128 | }); |
| 99 | interrupted: Cause.hasInterruptsOnly(commandResult.cause), | 129 | const retry = action === "retry"; |
| 100 | }); | 130 | console.warn("[thread-outbox] queued message delivery failed", { |
| 101 | const retry = action === "retry"; | 131 | environmentId: queuedMessage.environmentId, |
| 102 | console.warn("[thread-outbox] queued message delivery failed", { | 132 | threadId: queuedMessage.threadId, |
| 133 | messageId: queuedMessage.messageId, | ||
| 134 | stage, | ||
| 135 | cause: commandResult.cause, | ||
| 136 | retry, | ||
| 137 | }); | ||
| 138 | return retry; | ||
| 139 | }; | ||
| 140 | const completeDelivery = async ( | ||
| 141 | deliveryResult: AtomCommandResult<unknown, unknown>, | ||
| 142 | ): Promise<boolean> => { | ||
| 143 | if (reportFailure(deliveryResult, "start-turn")) { | ||
| 144 | return false; | ||
| 145 | } | ||
| 146 | |||
| 147 | try { | ||
| 148 | await removeThreadOutboxMessage(queuedMessage); | ||
| 149 | return true; | ||
| 150 | } catch (error) { | ||
| 151 | console.warn("[thread-outbox] failed to remove delivered queued message", { | ||
| 103 | environmentId: queuedMessage.environmentId, | 152 | environmentId: queuedMessage.environmentId, |
| 104 | threadId: queuedMessage.threadId, | 153 | threadId: queuedMessage.threadId, |
| 105 | messageId: queuedMessage.messageId, | 154 | messageId: queuedMessage.messageId, |
| 106 | stage, | 155 | error, |
| 107 | cause: commandResult.cause, | ||
| 108 | retry, | ||
| 109 | }); | 156 | }); |
| 110 | return retry; | 157 | return false; |
| 111 | }; | 158 | } |
| 112 | const completeDelivery = async ( | 159 | }; |
| 113 | deliveryResult: AtomCommandResult<unknown, unknown>, | 160 | return { reportFailure, completeDelivery }; |
| 114 | ): Promise<boolean> => { | 161 | }, []); |
| 115 | if (reportFailure(deliveryResult, "start-turn")) { | ||
| 116 | return false; | ||
| 117 | } | ||
| 118 | 162 | ||
| 119 | try { | 163 | const sendQueuedMessage = useCallback( |
| 120 | await removeThreadOutboxMessage(queuedMessage); | 164 | async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { |
| 121 | return true; | 165 | const settings = resolveQueuedThreadSettings(queuedMessage, thread); |
| 122 | } catch (error) { | 166 | const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); |
| 123 | console.warn("[thread-outbox] failed to remove delivered queued message", { | ||
| 124 | environmentId: queuedMessage.environmentId, | ||
| 125 | threadId: queuedMessage.threadId, | ||
| 126 | messageId: queuedMessage.messageId, | ||
| 127 | error, | ||
| 128 | }); | ||
| 129 | return false; | ||
| 130 | } | ||
| 131 | }; | ||
| 132 | 167 | ||
| 133 | if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { | 168 | if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { |
| 134 | const updateResult = await updateThreadMetadata({ | 169 | const updateResult = await updateThreadMetadata({ |
| ... | @@ -196,7 +231,50 @@ export function useThreadOutboxDrain(): void { | ... | @@ -196,7 +231,50 @@ export function useThreadOutboxDrain(): void { |
| 196 | }); | 231 | }); |
| 197 | return completeDelivery(deliveryResult); | 232 | return completeDelivery(deliveryResult); |
| 198 | }, | 233 | }, |
| 199 | [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], | 234 | [ |
| 235 | makeDeliveryHelpers, | ||
| 236 | setThreadInteractionMode, | ||
| 237 | setThreadRuntimeMode, | ||
| 238 | startTurn, | ||
| 239 | updateThreadMetadata, | ||
| 240 | ], | ||
| 241 | ); | ||
| 242 | |||
| 243 | const sendQueuedCreation = useCallback( | ||
| 244 | async ( | ||
| 245 | queuedMessage: QueuedThreadMessage, | ||
| 246 | creation: QueuedThreadCreation, | ||
| 247 | projectCwd: string, | ||
| 248 | ) => { | ||
| 249 | const modelSelection = queuedMessage.modelSelection; | ||
| 250 | if (modelSelection === undefined) { | ||
| 251 | return false; | ||
| 252 | } | ||
| 253 | const { completeDelivery } = makeDeliveryHelpers(queuedMessage); | ||
| 254 | const deliveryResult = await startTurn({ | ||
| 255 | environmentId: queuedMessage.environmentId, | ||
| 256 | input: buildProjectThreadStartTurnInput({ | ||
| 257 | projectId: creation.projectId, | ||
| 258 | projectCwd, | ||
| 259 | threadId: queuedMessage.threadId, | ||
| 260 | commandId: queuedMessage.commandId, | ||
| 261 | messageId: queuedMessage.messageId, | ||
| 262 | createdAt: queuedMessage.createdAt, | ||
| 263 | text: queuedMessage.text.trim(), | ||
| 264 | attachments: queuedMessage.attachments, | ||
| 265 | modelSelection, | ||
| 266 | runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, | ||
| 267 | interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 268 | workspaceMode: creation.workspaceMode, | ||
| 269 | branch: creation.branch, | ||
| 270 | worktreePath: creation.worktreePath, | ||
| 271 | startFromOrigin: creation.startFromOrigin ?? false, | ||
| 272 | worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), | ||
| 273 | }), | ||
| 274 | }); | ||
| 275 | return completeDelivery(deliveryResult); | ||
| 276 | }, | ||
| 277 | [makeDeliveryHelpers, startTurn], | ||
| 200 | ); | 278 | ); |
| 201 | 279 | ||
| 202 | useEffect(() => { | 280 | useEffect(() => { |
| ... | @@ -209,6 +287,9 @@ export function useThreadOutboxDrain(): void { | ... | @@ -209,6 +287,9 @@ export function useThreadOutboxDrain(): void { |
| 209 | if (!nextQueuedMessage) { | 287 | if (!nextQueuedMessage) { |
| 210 | continue; | 288 | continue; |
| 211 | } | 289 | } |
| 290 | if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { | ||
| 291 | continue; | ||
| 292 | } | ||
| 212 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { | 293 | if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { |
| 213 | continue; | 294 | continue; |
| 214 | } | 295 | } |
| ... | @@ -218,37 +299,65 @@ export function useThreadOutboxDrain(): void { | ... | @@ -218,37 +299,65 @@ export function useThreadOutboxDrain(): void { |
| 218 | continue; | 299 | continue; |
| 219 | } | 300 | } |
| 220 | 301 | ||
| 302 | const creation = nextQueuedMessage.creation; | ||
| 221 | const environment = connectedEnvironments.find( | 303 | const environment = connectedEnvironments.find( |
| 222 | (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, | 304 | (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, |
| 223 | ); | 305 | ); |
| 306 | const shellStatus = shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty"; | ||
| 224 | const deliveryAction = resolveThreadOutboxDeliveryAction({ | 307 | const deliveryAction = resolveThreadOutboxDeliveryAction({ |
| 308 | isCreation: creation !== undefined, | ||
| 225 | threadExists: thread !== undefined, | 309 | threadExists: thread !== undefined, |
| 226 | shellStatus: shellStatuses.get(nextQueuedMessage.environmentId) ?? "empty", | 310 | shellStatus, |
| 227 | environmentConnected: environment?.connectionState === "connected", | 311 | environmentConnected: environment?.connectionState === "connected", |
| 228 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", | 312 | threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", |
| 229 | }); | 313 | }); |
| 230 | if (deliveryAction === "wait") { | 314 | if (deliveryAction === "wait") { |
| 231 | continue; | 315 | continue; |
| 232 | } | 316 | } |
| 317 | // The live project shell is preferred for the workspace path, with the | ||
| 318 | // snapshot taken at enqueue time as the fallback so a task never dies | ||
| 319 | // just because its project shell is not loaded. | ||
| 320 | const creationProjectCwd = | ||
| 321 | creation !== undefined | ||
| 322 | ? (findCreationProject(projects, nextQueuedMessage)?.workspaceRoot ?? | ||
| 323 | creation.projectCwd ?? | ||
| 324 | null) | ||
| 325 | : null; | ||
| 326 | // An incomplete pending task (e.g. worktree mode without a branch) stays | ||
| 327 | // queued until the user finishes it in the editor. | ||
| 328 | if (deliveryAction === "send" && creation !== undefined) { | ||
| 329 | if (!isQueuedThreadCreationSendable(nextQueuedMessage)) { | ||
| 330 | continue; | ||
| 331 | } | ||
| 332 | if (creationProjectCwd === null && shellStatus !== "live") { | ||
| 333 | continue; | ||
| 334 | } | ||
| 335 | } | ||
| 233 | 336 | ||
| 234 | beginDispatchingQueuedMessage(nextQueuedMessage.messageId); | 337 | beginDispatchingQueuedMessage(nextQueuedMessage.messageId); |
| 338 | const removeQueuedMessage = (warning: string) => | ||
| 339 | removeThreadOutboxMessage(nextQueuedMessage).then( | ||
| 340 | () => true, | ||
| 341 | (error) => { | ||
| 342 | console.warn(warning, { | ||
| 343 | environmentId: nextQueuedMessage.environmentId, | ||
| 344 | threadId: nextQueuedMessage.threadId, | ||
| 345 | messageId: nextQueuedMessage.messageId, | ||
| 346 | error, | ||
| 347 | }); | ||
| 348 | return false; | ||
| 349 | }, | ||
| 350 | ); | ||
| 235 | const delivery = | 351 | const delivery = |
| 236 | deliveryAction === "remove" | 352 | deliveryAction === "remove" |
| 237 | ? removeThreadOutboxMessage(nextQueuedMessage).then( | 353 | ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") |
| 238 | () => true, | 354 | : creation !== undefined |
| 239 | (error) => { | 355 | ? creationProjectCwd !== null |
| 240 | console.warn("[thread-outbox] failed to remove message for a missing thread", { | 356 | ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) |
| 241 | environmentId: nextQueuedMessage.environmentId, | 357 | : removeQueuedMessage("[thread-outbox] dropped pending task for a missing project") |
| 242 | threadId: nextQueuedMessage.threadId, | 358 | : thread !== undefined |
| 243 | messageId: nextQueuedMessage.messageId, | 359 | ? sendQueuedMessage(nextQueuedMessage, thread) |
| 244 | error, | 360 | : Promise.resolve(false); |
| 245 | }); | ||
| 246 | return false; | ||
| 247 | }, | ||
| 248 | ) | ||
| 249 | : thread !== undefined | ||
| 250 | ? sendQueuedMessage(nextQueuedMessage, thread) | ||
| 251 | : Promise.resolve(false); | ||
| 252 | void delivery | 361 | void delivery |
| 253 | .then((sent) => { | 362 | .then((sent) => { |
| 254 | if (sent) { | 363 | if (sent) { |
| ... | @@ -284,8 +393,11 @@ export function useThreadOutboxDrain(): void { | ... | @@ -284,8 +393,11 @@ export function useThreadOutboxDrain(): void { |
| 284 | }, [ | 393 | }, [ |
| 285 | connectedEnvironments, | 394 | connectedEnvironments, |
| 286 | dispatchingQueuedMessageId, | 395 | dispatchingQueuedMessageId, |
| 396 | editingQueuedMessageIds, | ||
| 397 | projects, | ||
| 287 | queuedMessagesByThreadKey, | 398 | queuedMessagesByThreadKey, |
| 288 | retryTick, | 399 | retryTick, |
| 400 | sendQueuedCreation, | ||
| 289 | sendQueuedMessage, | 401 | sendQueuedMessage, |
| 290 | shellStatuses, | 402 | shellStatuses, |
| 291 | threads, | 403 | threads, |
apps/mobile/src/state/use-thread-outbox.ts+31-1| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, MessageId } from "@t3tools/contracts"; |
| 4 | import { Atom } from "effect/unstable/reactivity"; | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | ||
| 6 | import { appAtomRegistry } from "./atom-registry"; | ||
| 6 | import { environmentShell } from "./shell"; | 7 | import { environmentShell } from "./shell"; |
| 7 | import { threadOutboxManager } from "./thread-outbox"; | 8 | import { threadOutboxManager } from "./thread-outbox"; |
| 8 | 9 | ||
| ... | @@ -19,6 +20,35 @@ const threadOutboxShellStatusesAtom = Atom.make( | ... | @@ -19,6 +20,35 @@ const threadOutboxShellStatusesAtom = Atom.make( |
| 19 | }, | 20 | }, |
| 20 | ).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses")); | 21 | ).pipe(Atom.withLabel("mobile:thread-outbox:shell-statuses")); |
| 21 | 22 | ||
| 23 | /** | ||
| 24 | * Queued pending tasks the outbox drain must not deliver right now: the one | ||
| 25 | * open in the new-task editor, plus any whose latest edits could not be saved | ||
| 26 | * back yet (delivering those would send stale content). Editing sessions hold | ||
| 27 | * their message id here and release it once the queued payload is current. | ||
| 28 | */ | ||
| 29 | export const editingQueuedMessageIdsAtom = Atom.make<Readonly<Record<MessageId, true>>>({}).pipe( | ||
| 30 | Atom.keepAlive, | ||
| 31 | Atom.withLabel("mobile:thread-outbox:editing-message-ids"), | ||
| 32 | ); | ||
| 33 | |||
| 34 | export function holdEditingQueuedMessage(messageId: MessageId): void { | ||
| 35 | const current = appAtomRegistry.get(editingQueuedMessageIdsAtom); | ||
| 36 | if (current[messageId]) { | ||
| 37 | return; | ||
| 38 | } | ||
| 39 | appAtomRegistry.set(editingQueuedMessageIdsAtom, { ...current, [messageId]: true }); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function releaseEditingQueuedMessage(messageId: MessageId): void { | ||
| 43 | const current = appAtomRegistry.get(editingQueuedMessageIdsAtom); | ||
| 44 | if (!current[messageId]) { | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | const next = { ...current }; | ||
| 48 | delete next[messageId]; | ||
| 49 | appAtomRegistry.set(editingQueuedMessageIdsAtom, next); | ||
| 50 | } | ||
| 51 | |||
| 22 | export function useThreadOutboxMessages() { | 52 | export function useThreadOutboxMessages() { |
| 23 | return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); | 53 | return useAtomValue(threadOutboxManager.queuedMessagesByThreadKeyAtom); |
| 24 | } | 54 | } |