| author | |
| committer | |
| log | e26e55f389fb0e31ee9a0c4570510bdcba8e350d |
| tree | 93b7be5ab7ddab9deb1e23d991978a51a531d7d1 |
| parent | 4ac094fef689b71f475e41a9a2fce167804a5e4e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>21 files changed, 653 insertions(+), 694 deletions(-)
apps/mobile/package.json+1-1| ... | ... | @@ -96,7 +96,7 @@ |
| 96 | 96 | "react-native": "0.85.3", |
| 97 | 97 | "react-native-gesture-handler": "~2.31.1", |
| 98 | 98 | "react-native-image-viewing": "^0.2.2", |
| 99 | "react-native-keyboard-controller": "1.21.6", | |
| 99 | "react-native-keyboard-controller": "1.21.13", | |
| 100 | 100 | "react-native-nitro-markdown": "^0.5.0", |
| 101 | 101 | "react-native-nitro-modules": "0.35.9", |
| 102 | 102 | "react-native-reanimated": "4.3.1", |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1-1| ... | ... | @@ -333,7 +333,7 @@ function ArchivedThreadRow(props: { |
| 333 | 333 | </Text> |
| 334 | 334 | <Text |
| 335 | 335 | className="text-xs text-foreground-tertiary" |
| 336 | style={{ fontVariant: ["tabular-nums"] }} | |
| 336 | style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }} | |
| 337 | 337 | > |
| 338 | 338 | {timestamp} |
| 339 | 339 | </Text> |
apps/mobile/src/features/home/HomeRouteScreen.tsx+10| ... | ... | @@ -122,6 +122,16 @@ export function HomeRouteScreen() { |
| 122 | 122 | }} |
| 123 | 123 | onSelectPendingTask={openPendingTask} |
| 124 | 124 | onDeletePendingTask={confirmDeletePendingTask} |
| 125 | onNewThreadInProject={(project) => { | |
| 126 | navigation.navigate("NewTaskSheet", { | |
| 127 | screen: "NewTaskDraft", | |
| 128 | params: { | |
| 129 | environmentId: String(project.environmentId), | |
| 130 | projectId: String(project.id), | |
| 131 | title: project.title, | |
| 132 | }, | |
| 133 | }); | |
| 134 | }} | |
| 125 | 135 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 126 | 136 | onThreadSortOrderChange={setThreadSortOrder} |
| 127 | 137 | pendingTasks={pendingTasks} |
apps/mobile/src/features/home/HomeScreen.tsx+8| ... | ... | @@ -72,6 +72,7 @@ interface HomeScreenProps { |
| 72 | 72 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 73 | 73 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 74 | 74 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 75 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | |
| 75 | 76 | } |
| 76 | 77 | |
| 77 | 78 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| ... | ... | @@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 248 | 249 | isFirst={item.isFirst} |
| 249 | 250 | groupKey={item.group.key} |
| 250 | 251 | onGroupAction={updateGroupDisplay} |
| 252 | // Aggregated groups (same repo across machines) have no single | |
| 253 | // target project, and `pending-project:` groups hold a placeholder | |
| 254 | // built from queued-task metadata rather than a real project shell, | |
| 255 | // so the quick new-thread button is single-real-project only. | |
| 256 | newThreadTarget={item.group.newThreadTarget} | |
| 257 | onNewThread={props.onNewThreadInProject} | |
| 251 | 258 | project={item.group.representative} |
| 252 | 259 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 253 | 260 | title={item.group.title} |
| ... | ... | @@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 308 | 315 | props.onArchiveThread, |
| 309 | 316 | props.onDeletePendingTask, |
| 310 | 317 | props.onDeleteThread, |
| 318 | props.onNewThreadInProject, | |
| 311 | 319 | props.onSelectPendingTask, |
| 312 | 320 | props.onSelectThread, |
| 313 | 321 | props.savedConnectionsById, |
apps/mobile/src/features/home/homeListItems.test.ts+50-3| ... | ... | @@ -57,15 +57,20 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { |
| 57 | 57 | |
| 58 | 58 | function makeGroup(key: string, threadCount: number): HomeThreadGroup { |
| 59 | 59 | const project = makeProject(key, key); |
| 60 | const threads = Array.from({ length: threadCount }, (_, index) => | |
| 61 | makeThread(`${key}-thread-${index}`, project.id), | |
| 62 | ); | |
| 60 | 63 | return { |
| 61 | 64 | key, |
| 62 | 65 | title: key, |
| 63 | 66 | representative: project, |
| 64 | 67 | projects: [project], |
| 65 | 68 | pendingTasks: [], |
| 66 | threads: Array.from({ length: threadCount }, (_, index) => | |
| 67 | makeThread(`${key}-thread-${index}`, project.id), | |
| 68 | ), | |
| 69 | threads, | |
| 70 | // All threads inside the recency window, so the baseline stays at the | |
| 71 | // initial page size and the pagination expectations below hold. | |
| 72 | recentThreads: threads, | |
| 73 | newThreadTarget: project, | |
| 69 | 74 | }; |
| 70 | 75 | } |
| 71 | 76 | |
| ... | ... | @@ -153,6 +158,48 @@ describe("buildHomeListLayout", () => { |
| 153 | 158 | expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); |
| 154 | 159 | }); |
| 155 | 160 | |
| 161 | it("offers show-less after expanding a stale group whose baseline is below the page size", () => { | |
| 162 | // Stale project: 10 threads total but only 3 within the recency window. | |
| 163 | const project = makeProject("stale", "stale"); | |
| 164 | const threads = Array.from({ length: 10 }, (_, index) => | |
| 165 | makeThread(`stale-thread-${index}`, project.id), | |
| 166 | ); | |
| 167 | const group: HomeThreadGroup = { | |
| 168 | key: "stale", | |
| 169 | title: "stale", | |
| 170 | representative: project, | |
| 171 | projects: [project], | |
| 172 | pendingTasks: [], | |
| 173 | threads, | |
| 174 | recentThreads: threads.slice(0, 3), | |
| 175 | newThreadTarget: project, | |
| 176 | }; | |
| 177 | ||
| 178 | const collapsedToRecent = buildHomeListLayout({ | |
| 179 | groups: [group], | |
| 180 | displayStates: displayStates({}), | |
| 181 | }); | |
| 182 | expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3); | |
| 183 | expect(collapsedToRecent.items.at(-1)).toMatchObject({ | |
| 184 | type: "show-more", | |
| 185 | hiddenCount: 7, | |
| 186 | canShowLess: false, | |
| 187 | }); | |
| 188 | ||
| 189 | const expanded = buildHomeListLayout({ | |
| 190 | groups: [group], | |
| 191 | displayStates: displayStates({ | |
| 192 | stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), | |
| 193 | }), | |
| 194 | }); | |
| 195 | expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10); | |
| 196 | expect(expanded.items.at(-1)).toMatchObject({ | |
| 197 | type: "show-more", | |
| 198 | hiddenCount: 0, | |
| 199 | canShowLess: true, | |
| 200 | }); | |
| 201 | }); | |
| 202 | ||
| 156 | 203 | it("hides threads and the show-more row for collapsed groups", () => { |
| 157 | 204 | const layout = buildHomeListLayout({ |
| 158 | 205 | groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], |
apps/mobile/src/features/home/homeListItems.ts+21-3| ... | ... | @@ -145,12 +145,27 @@ export function buildHomeListLayout(input: { |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | 147 | const totalCount = group.threads.length; |
| 148 | // Default to the group's recent-activity window (last few days, or a small | |
| 149 | // fallback for stale projects), capped at the initial page size. Until the | |
| 150 | // user taps "Show more", older threads stay hidden to save vertical space; | |
| 151 | // "Show less" resets visibleCount to the initial constant, which lands back | |
| 152 | // here at the recency baseline. | |
| 153 | const baselineCount = Math.min( | |
| 154 | group.recentThreads.length, | |
| 155 | HOME_INITIAL_VISIBLE_THREADS, | |
| 156 | totalCount, | |
| 157 | ); | |
| 148 | 158 | const visibleCount = input.showAllThreads |
| 149 | 159 | ? totalCount |
| 150 | : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount); | |
| 160 | : Math.min( | |
| 161 | display.visibleCount > HOME_INITIAL_VISIBLE_THREADS | |
| 162 | ? display.visibleCount | |
| 163 | : baselineCount, | |
| 164 | totalCount, | |
| 165 | ); | |
| 151 | 166 | const visibleThreads = group.threads.slice(0, visibleCount); |
| 152 | 167 | const hiddenCount = totalCount - visibleCount; |
| 153 | const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS; | |
| 168 | const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount; | |
| 154 | 169 | |
| 155 | 170 | // Pending (unsent) tasks lead the group and are never paginated away. |
| 156 | 171 | for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { |
| ... | ... | @@ -180,7 +195,10 @@ export function buildHomeListLayout(input: { |
| 180 | 195 | key: `show-more:${group.key}`, |
| 181 | 196 | groupKey: group.key, |
| 182 | 197 | hiddenCount, |
| 183 | canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS, | |
| 198 | // Compare against the group's own baseline, not the global page size: | |
| 199 | // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show | |
| 200 | // less" must be offered as soon as anything beyond the baseline shows. | |
| 201 | canShowLess: visibleCount > baselineCount, | |
| 184 | 202 | }); |
| 185 | 203 | } |
| 186 | 204 | } |
apps/mobile/src/features/home/homeThreadList.test.ts+143| ... | ... | @@ -44,6 +44,8 @@ function makeThread( |
| 44 | 44 | }; |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); | |
| 48 | ||
| 47 | 49 | function buildGroups( |
| 48 | 50 | projects: ReadonlyArray<EnvironmentProject>, |
| 49 | 51 | threads: ReadonlyArray<EnvironmentThreadShell>, |
| ... | ... | @@ -57,6 +59,7 @@ function buildGroups( |
| 57 | 59 | projectSortOrder: "updated_at", |
| 58 | 60 | threadSortOrder: "updated_at", |
| 59 | 61 | projectGroupingMode: "repository", |
| 62 | now: NOW, | |
| 60 | 63 | ...overrides, |
| 61 | 64 | }); |
| 62 | 65 | } |
| ... | ... | @@ -220,4 +223,144 @@ describe("buildHomeThreadGroups", () => { |
| 220 | 223 | ); |
| 221 | 224 | expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); |
| 222 | 225 | }); |
| 226 | ||
| 227 | it("default view shows only threads from the last 5 days", () => { | |
| 228 | const environmentId = EnvironmentId.make("environment-1"); | |
| 229 | const project = makeProject({ | |
| 230 | environmentId, | |
| 231 | id: ProjectId.make("project-1"), | |
| 232 | title: "T3 Code", | |
| 233 | }); | |
| 234 | const threads = [ | |
| 235 | makeThread({ | |
| 236 | environmentId, | |
| 237 | id: ThreadId.make("recent-1"), | |
| 238 | projectId: project.id, | |
| 239 | title: "Today", | |
| 240 | updatedAt: "2026-06-28T00:00:00.000Z", | |
| 241 | }), | |
| 242 | makeThread({ | |
| 243 | environmentId, | |
| 244 | id: ThreadId.make("recent-2"), | |
| 245 | projectId: project.id, | |
| 246 | title: "Within window", | |
| 247 | updatedAt: "2026-06-25T00:00:00.000Z", | |
| 248 | }), | |
| 249 | makeThread({ | |
| 250 | environmentId, | |
| 251 | id: ThreadId.make("old"), | |
| 252 | projectId: project.id, | |
| 253 | title: "Two weeks ago", | |
| 254 | updatedAt: "2026-06-14T00:00:00.000Z", | |
| 255 | }), | |
| 256 | ]; | |
| 257 | ||
| 258 | const group = buildGroups([project], threads)[0]; | |
| 259 | // Default view trims to recent threads... | |
| 260 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); | |
| 261 | // ...while full history stays available for the expanded view. | |
| 262 | expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); | |
| 263 | }); | |
| 264 | ||
| 265 | it("falls back to the most recent 3 threads when none are within 5 days", () => { | |
| 266 | const environmentId = EnvironmentId.make("environment-1"); | |
| 267 | const project = makeProject({ | |
| 268 | environmentId, | |
| 269 | id: ProjectId.make("project-1"), | |
| 270 | title: "T3 Code", | |
| 271 | }); | |
| 272 | const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( | |
| 273 | (day, index) => | |
| 274 | makeThread({ | |
| 275 | environmentId, | |
| 276 | id: ThreadId.make(`thread-${index}`), | |
| 277 | projectId: project.id, | |
| 278 | title: `Thread ${index}`, | |
| 279 | updatedAt: `${day}T00:00:00.000Z`, | |
| 280 | }), | |
| 281 | ); | |
| 282 | ||
| 283 | const group = buildGroups([project], threads)[0]; | |
| 284 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ | |
| 285 | "thread-4", | |
| 286 | "thread-3", | |
| 287 | "thread-2", | |
| 288 | ]); | |
| 289 | expect(group?.threads).toHaveLength(5); | |
| 290 | }); | |
| 291 | ||
| 292 | it("does not apply the recency window while searching", () => { | |
| 293 | const environmentId = EnvironmentId.make("environment-1"); | |
| 294 | const project = makeProject({ | |
| 295 | environmentId, | |
| 296 | id: ProjectId.make("project-1"), | |
| 297 | title: "T3 Code", | |
| 298 | }); | |
| 299 | const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( | |
| 300 | (day, index) => | |
| 301 | makeThread({ | |
| 302 | environmentId, | |
| 303 | id: ThreadId.make(`thread-${index}`), | |
| 304 | projectId: project.id, | |
| 305 | title: `Thread ${index}`, | |
| 306 | updatedAt: `${day}T00:00:00.000Z`, | |
| 307 | }), | |
| 308 | ); | |
| 309 | ||
| 310 | const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; | |
| 311 | // Search reaches the full history rather than the 3-thread fallback. | |
| 312 | expect(group?.recentThreads).toHaveLength(5); | |
| 313 | expect(group?.recentThreads.map((thread) => thread.id)).toEqual( | |
| 314 | group?.threads.map((thread) => thread.id), | |
| 315 | ); | |
| 316 | }); | |
| 317 | ||
| 318 | it("targets quick new threads at the group member with the newest thread", () => { | |
| 319 | const laptopEnv = EnvironmentId.make("environment-laptop"); | |
| 320 | const desktopEnv = EnvironmentId.make("environment-desktop"); | |
| 321 | const repositoryIdentity = { | |
| 322 | canonicalKey: "github.com/pingdotgg/t3code", | |
| 323 | locator: { | |
| 324 | source: "git-remote" as const, | |
| 325 | remoteName: "origin", | |
| 326 | remoteUrl: "git@github.com:pingdotgg/t3code.git", | |
| 327 | }, | |
| 328 | }; | |
| 329 | const laptopProject = makeProject({ | |
| 330 | environmentId: laptopEnv, | |
| 331 | id: ProjectId.make("project-laptop"), | |
| 332 | title: "t3code", | |
| 333 | repositoryIdentity, | |
| 334 | }); | |
| 335 | const desktopProject = makeProject({ | |
| 336 | environmentId: desktopEnv, | |
| 337 | id: ProjectId.make("project-desktop"), | |
| 338 | title: "t3code", | |
| 339 | repositoryIdentity, | |
| 340 | }); | |
| 341 | const threads = [ | |
| 342 | makeThread({ | |
| 343 | environmentId: laptopEnv, | |
| 344 | id: ThreadId.make("thread-laptop"), | |
| 345 | projectId: laptopProject.id, | |
| 346 | title: "Older laptop thread", | |
| 347 | updatedAt: "2026-06-27T00:00:00.000Z", | |
| 348 | }), | |
| 349 | makeThread({ | |
| 350 | environmentId: desktopEnv, | |
| 351 | id: ThreadId.make("thread-desktop"), | |
| 352 | projectId: desktopProject.id, | |
| 353 | title: "Newest desktop thread", | |
| 354 | updatedAt: "2026-06-28T00:00:00.000Z", | |
| 355 | }), | |
| 356 | ]; | |
| 357 | ||
| 358 | // Aggregated into one group by repository; the quick new-thread target | |
| 359 | // must follow the newest thread (desktop), not the arbitrary first member. | |
| 360 | const groups = buildGroups([laptopProject, desktopProject], threads); | |
| 361 | expect(groups).toHaveLength(1); | |
| 362 | expect(groups[0]?.projects).toHaveLength(2); | |
| 363 | expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv); | |
| 364 | expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id); | |
| 365 | }); | |
| 223 | 366 | }); |
apps/mobile/src/features/home/homeThreadList.ts+73-1| ... | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | 14 | SidebarThreadSortOrder, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | 16 | import * as Arr from "effect/Array"; |
| 17 | import * as Option from "effect/Option"; | |
| 17 | 18 | import * as Order from "effect/Order"; |
| 18 | 19 | |
| 19 | 20 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| ... | ... | @@ -21,13 +22,33 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 21 | 22 | |
| 22 | 23 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| 23 | 24 | |
| 25 | /** | |
| 26 | * Default home view only surfaces threads active within this window, to keep the | |
| 27 | * screen compact while keeping recent work visible. | |
| 28 | */ | |
| 29 | const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; | |
| 30 | /** Fallback when a project has no threads inside the recency window. */ | |
| 31 | const RECENT_THREAD_FALLBACK_COUNT = 3; | |
| 32 | ||
| 24 | 33 | export interface HomeThreadGroup { |
| 25 | 34 | readonly key: string; |
| 26 | 35 | readonly title: string; |
| 27 | 36 | readonly representative: EnvironmentProject; |
| 28 | 37 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 29 | 38 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 39 | /** Full sorted thread history for the group (revealed when expanded / searching). */ | |
| 30 | 40 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 41 | /** Subset shown by default: threads from the last few days, or the most recent few. */ | |
| 42 | readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>; | |
| 43 | /** | |
| 44 | * Where a quick "new thread in this project" should land. For aggregated | |
| 45 | * groups (same repo on several machines) this is the member that owns the | |
| 46 | * group's most recent thread — the machine the user last worked on — rather | |
| 47 | * than the arbitrary first member; the draft's computer picker covers | |
| 48 | * switching from there. Null only for synthetic pending-project groups, | |
| 49 | * whose single "project" is a placeholder built from queued-task metadata. | |
| 50 | */ | |
| 51 | readonly newThreadTarget: EnvironmentProject | null; | |
| 31 | 52 | } |
| 32 | 53 | |
| 33 | 54 | interface MutableHomeThreadGroup { |
| ... | ... | @@ -48,6 +69,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr |
| 48 | 69 | }, latestThread); |
| 49 | 70 | } |
| 50 | 71 | |
| 72 | /** | |
| 73 | * Trims a group's threads to recent activity for the default home view. | |
| 74 | * `sortedThreads` must already be ordered newest-first for `threadSortOrder`. | |
| 75 | * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps | |
| 76 | * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes. | |
| 77 | */ | |
| 78 | function selectRecentThreads( | |
| 79 | sortedThreads: ReadonlyArray<EnvironmentThreadShell>, | |
| 80 | threadSortOrder: SidebarThreadSortOrder, | |
| 81 | now: number, | |
| 82 | ): ReadonlyArray<EnvironmentThreadShell> { | |
| 83 | const cutoff = now - RECENT_THREAD_WINDOW_MS; | |
| 84 | const recent = sortedThreads.filter( | |
| 85 | (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff, | |
| 86 | ); | |
| 87 | return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); | |
| 88 | } | |
| 89 | ||
| 51 | 90 | export function buildHomeThreadGroups(input: { |
| 52 | 91 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 53 | 92 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| ... | ... | @@ -57,7 +96,10 @@ export function buildHomeThreadGroups(input: { |
| 57 | 96 | readonly projectSortOrder: HomeProjectSortOrder; |
| 58 | 97 | readonly threadSortOrder: SidebarThreadSortOrder; |
| 59 | 98 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 99 | /** Current time used for the recency window; defaults to now. Injectable for tests. */ | |
| 100 | readonly now?: number; | |
| 60 | 101 | }): ReadonlyArray<HomeThreadGroup> { |
| 102 | const now = input.now ?? Date.now(); | |
| 61 | 103 | const groups = new Map<string, MutableHomeThreadGroup>(); |
| 62 | 104 | const groupKeyByProjectKey = new Map<string, string>(); |
| 63 | 105 | |
| ... | ... | @@ -165,13 +207,43 @@ export function buildHomeThreadGroups(input: { |
| 165 | 207 | continue; |
| 166 | 208 | } |
| 167 | 209 | |
| 210 | const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder); | |
| 211 | // An active search should reach the full history, so the recency window | |
| 212 | // only trims the default (no-query) view. | |
| 213 | const recentThreads = | |
| 214 | query.length === 0 | |
| 215 | ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) | |
| 216 | : sortedThreads; | |
| 217 | ||
| 218 | // Sorted newest-first, so the first thread whose project is a group member | |
| 219 | // marks the machine the user last worked on. | |
| 220 | const lastActiveProject = Arr.findFirst(sortedThreads, (thread) => | |
| 221 | group.projects.some( | |
| 222 | (project) => | |
| 223 | project.environmentId === thread.environmentId && project.id === thread.projectId, | |
| 224 | ), | |
| 225 | ).pipe( | |
| 226 | Option.flatMap((thread) => | |
| 227 | Arr.findFirst( | |
| 228 | group.projects, | |
| 229 | (project) => | |
| 230 | project.environmentId === thread.environmentId && project.id === thread.projectId, | |
| 231 | ), | |
| 232 | ), | |
| 233 | Option.getOrNull, | |
| 234 | ); | |
| 235 | ||
| 168 | 236 | result.push({ |
| 169 | 237 | key: group.key, |
| 170 | 238 | title, |
| 171 | 239 | representative, |
| 172 | 240 | projects: group.projects, |
| 173 | 241 | pendingTasks: matchingPendingTasks, |
| 174 | threads: sortThreads(matchingThreads, input.threadSortOrder), | |
| 242 | threads: sortedThreads, | |
| 243 | recentThreads, | |
| 244 | newThreadTarget: group.key.startsWith("pending-project:") | |
| 245 | ? null | |
| 246 | : (lastActiveProject ?? representative), | |
| 175 | 247 | }); |
| 176 | 248 | } |
| 177 | 249 |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+19-1| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 2 | 5 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | 6 | import { useFocusEffect } from "@react-navigation/native"; |
| 4 | 7 | import { |
| ... | ... | @@ -401,6 +404,20 @@ export function AdaptiveWorkspaceLayout(props: { |
| 401 | 404 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); |
| 402 | 405 | }, [navigation]); |
| 403 | 406 | |
| 407 | const handleNewThreadInProject = useCallback( | |
| 408 | (project: EnvironmentProject) => { | |
| 409 | navigation.navigate("NewTaskSheet", { | |
| 410 | screen: "NewTaskDraft", | |
| 411 | params: { | |
| 412 | environmentId: String(project.environmentId), | |
| 413 | projectId: String(project.id), | |
| 414 | title: project.title, | |
| 415 | }, | |
| 416 | }); | |
| 417 | }, | |
| 418 | [navigation], | |
| 419 | ); | |
| 420 | ||
| 404 | 421 | const renderedSidebarWidth = useSharedValue( |
| 405 | 422 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, |
| 406 | 423 | ); |
| ... | ... | @@ -477,6 +494,7 @@ export function AdaptiveWorkspaceLayout(props: { |
| 477 | 494 | selectedThreadKey={selectedThreadKey} |
| 478 | 495 | onOpenSettings={handleOpenSettings} |
| 479 | 496 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} |
| 497 | onNewThreadInProject={handleNewThreadInProject} | |
| 480 | 498 | onSelectThread={handleSelectThread} |
| 481 | 499 | onSearchQueryChange={setPrimarySidebarSearchQuery} |
| 482 | 500 | searchQuery={primarySidebarSearchQuery} |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+6-5| ... | ... | @@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: { |
| 111 | 111 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 112 | 112 | |
| 113 | 113 | const borderColor = useThemeColor("--color-border"); |
| 114 | const bodyText = useScaledTextRole("body"); | |
| 114 | const headlineText = useScaledTextRole("headline"); | |
| 115 | 115 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; |
| 116 | 116 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; |
| 117 | 117 | |
| ... | ... | @@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: { |
| 129 | 129 | lastInitialProjectRefRef.current = props.initialProjectRef; |
| 130 | 130 | appliedInitialProjectKeyRef.current = null; |
| 131 | 131 | } |
| 132 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { | |
| 132 | const initialEnvironmentId = props.initialProjectRef?.environmentId; | |
| 133 | const initialProjectId = props.initialProjectRef?.projectId; | |
| 134 | if (initialEnvironmentId && initialProjectId) { | |
| 133 | 135 | const directProject = |
| 134 | 136 | projects.find( |
| 135 | 137 | (project) => |
| 136 | project.environmentId === props.initialProjectRef?.environmentId && | |
| 137 | project.id === props.initialProjectRef?.projectId, | |
| 138 | project.environmentId === initialEnvironmentId && project.id === initialProjectId, | |
| 138 | 139 | ) ?? null; |
| 139 | 140 | |
| 140 | 141 | if (directProject) { |
| ... | ... | @@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: { |
| 602 | 603 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 603 | 604 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 604 | 605 | style={{ flex: 1, minHeight: 0 }} |
| 605 | textStyle={bodyText} | |
| 606 | textStyle={headlineText} | |
| 606 | 607 | /> |
| 607 | 608 | </View> |
| 608 | 609 |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+129-143| ... | ... | @@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 18 | 18 | import * as Haptics from "expo-haptics"; |
| 19 | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | 20 | import { View, type GestureResponderEvent } from "react-native"; |
| 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 22 | import { KeyboardStickyView } from "react-native-keyboard-controller"; | |
| 21 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; | |
| 23 | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { runOnJS } from "react-native-reanimated"; | |
| 25 | 23 | |
| 26 | 24 | import { AppText as Text } from "../../components/AppText"; |
| 27 | 25 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | ... | @@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps { |
| 73 | 71 | readonly layoutVariant?: LayoutVariant; |
| 74 | 72 | readonly usesAutomaticContentInsets?: boolean; |
| 75 | 73 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 76 | readonly onOpenDrawer: () => void; | |
| 77 | 74 | readonly onOpenConnectionEditor: () => void; |
| 78 | 75 | readonly onChangeDraftMessage: (value: string) => void; |
| 79 | 76 | readonly onPickDraftImages: () => Promise<void>; |
| ... | ... | @@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 208 | 205 | }); |
| 209 | 206 | |
| 210 | 207 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 211 | const { onOpenDrawer } = props; | |
| 212 | ||
| 213 | 208 | const insets = useSafeAreaInsets(); |
| 214 | 209 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 215 | 210 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| ... | ... | @@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 263 | 258 | [props.serverConfig, selectedInstanceId], |
| 264 | 259 | ); |
| 265 | 260 | |
| 266 | const completeDrawerGesture = useCallback(() => { | |
| 267 | void Haptics.selectionAsync(); | |
| 268 | onOpenDrawer(); | |
| 269 | }, [onOpenDrawer]); | |
| 270 | ||
| 271 | const drawerGestureThreshold = 80; | |
| 272 | const headerDrawerGesture = useMemo( | |
| 273 | () => | |
| 274 | Gesture.Pan() | |
| 275 | .enabled(!isSplitLayout) | |
| 276 | .hitSlop({ left: 0, width: 40 }) | |
| 277 | .activeOffsetX([10, 999]) | |
| 278 | .failOffsetY([-24, 24]) | |
| 279 | .onEnd((event) => { | |
| 280 | const translationX = Math.max(event.translationX, 0); | |
| 281 | if (event.y < drawerGestureThreshold && translationX > 56) { | |
| 282 | runOnJS(completeDrawerGesture)(); | |
| 283 | } | |
| 284 | }), | |
| 285 | [completeDrawerGesture, isSplitLayout], | |
| 286 | ); | |
| 287 | ||
| 288 | 261 | useLayoutEffect(() => { |
| 289 | 262 | selectedThreadKeyRef.current = selectedThreadKey; |
| 290 | 263 | }, [selectedThreadKey]); |
| ... | ... | @@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 311 | 284 | return; |
| 312 | 285 | } |
| 313 | 286 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; |
| 314 | void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { | |
| 315 | if ( | |
| 316 | selectedThreadKeyRef.current !== targetThreadKey || | |
| 317 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | |
| 318 | ) { | |
| 319 | return; | |
| 320 | } | |
| 321 | lastScrolledAnchorMessageIdRef.current = null; | |
| 322 | freeze.set(false); | |
| 323 | }); | |
| 287 | // Wait for the keyboard dismissal (started by blur() on send) to finish | |
| 288 | // before scrolling: scrollMessageToEnd freezes keyboard-driven inset | |
| 289 | // updates while it runs, and a close event swallowed by that freeze | |
| 290 | // leaves the keyboard padding permanently applied — overshooting the | |
| 291 | // anchor and leaving a phantom bottom inset once the reply streams in. | |
| 292 | void KeyboardController.dismiss() | |
| 293 | .then(() => { | |
| 294 | if ( | |
| 295 | selectedThreadKeyRef.current !== targetThreadKey || | |
| 296 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | |
| 297 | ) { | |
| 298 | return; | |
| 299 | } | |
| 300 | return scrollMessageToEnd({ animated: true, closeKeyboard: false }); | |
| 301 | }) | |
| 302 | .catch(() => { | |
| 303 | if ( | |
| 304 | selectedThreadKeyRef.current !== targetThreadKey || | |
| 305 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | |
| 306 | ) { | |
| 307 | return; | |
| 308 | } | |
| 309 | lastScrolledAnchorMessageIdRef.current = null; | |
| 310 | freeze.set(false); | |
| 311 | }); | |
| 324 | 312 | }); |
| 325 | 313 | return () => cancelAnimationFrame(frame); |
| 326 | 314 | }, [ |
| ... | ... | @@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 379 | 367 | }, []); |
| 380 | 368 | |
| 381 | 369 | return ( |
| 382 | <GestureDetector gesture={headerDrawerGesture}> | |
| 383 | <View className="flex-1"> | |
| 384 | {showContent ? ( | |
| 370 | <View className="flex-1"> | |
| 371 | {showContent ? ( | |
| 372 | <View | |
| 373 | style={{ flex: 1 }} | |
| 374 | onTouchStart={handleFeedTouchStart} | |
| 375 | onTouchMove={handleFeedTouchMove} | |
| 376 | onTouchEnd={handleFeedTouchEnd} | |
| 377 | onTouchCancel={handleFeedTouchCancel} | |
| 378 | > | |
| 379 | <ThreadFeed | |
| 380 | key={props.selectedThread.id} | |
| 381 | environmentId={props.environmentId} | |
| 382 | threadId={props.selectedThread.id} | |
| 383 | workspaceRoot={props.threadCwd} | |
| 384 | feed={props.selectedThreadFeed} | |
| 385 | contentPresentation={props.contentPresentation} | |
| 386 | agentLabel={agentLabel} | |
| 387 | latestTurn={props.selectedThread.latestTurn} | |
| 388 | listRef={listRef} | |
| 389 | freeze={freeze} | |
| 390 | anchorMessageId={anchorMessageId} | |
| 391 | contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 392 | contentTopInset={0} | |
| 393 | contentBottomInset={estimatedOverlayHeight} | |
| 394 | contentMaxWidth={contentMaxWidth} | |
| 395 | layoutVariant={layoutVariant} | |
| 396 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | |
| 397 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | |
| 398 | skills={selectedProviderSkills} | |
| 399 | /> | |
| 400 | </View> | |
| 401 | ) : ( | |
| 402 | <View style={{ flex: 1 }} /> | |
| 403 | )} | |
| 404 | ||
| 405 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | |
| 406 | {showContent ? ( | |
| 407 | <KeyboardStickyView | |
| 408 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 409 | offset={{ closed: 0, opened: 0 }} | |
| 410 | > | |
| 385 | 411 | <View |
| 386 | style={{ flex: 1 }} | |
| 387 | onTouchStart={handleFeedTouchStart} | |
| 388 | onTouchMove={handleFeedTouchMove} | |
| 389 | onTouchEnd={handleFeedTouchEnd} | |
| 390 | onTouchCancel={handleFeedTouchCancel} | |
| 412 | ref={composerOverlayRef} | |
| 413 | onLayout={onComposerLayout} | |
| 414 | style={{ width: "100%", paddingTop: 8 }} | |
| 391 | 415 | > |
| 392 | <ThreadFeed | |
| 393 | key={props.selectedThread.id} | |
| 394 | environmentId={props.environmentId} | |
| 395 | threadId={props.selectedThread.id} | |
| 396 | workspaceRoot={props.threadCwd} | |
| 397 | feed={props.selectedThreadFeed} | |
| 398 | contentPresentation={props.contentPresentation} | |
| 399 | agentLabel={agentLabel} | |
| 400 | latestTurn={props.selectedThread.latestTurn} | |
| 401 | listRef={listRef} | |
| 402 | freeze={freeze} | |
| 403 | anchorMessageId={anchorMessageId} | |
| 404 | contentInsetEndAdjustment={contentInsetEndAdjustment} | |
| 405 | contentTopInset={0} | |
| 406 | contentBottomInset={estimatedOverlayHeight} | |
| 416 | <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> | |
| 417 | {props.activeWorkStartedAt ? ( | |
| 418 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | |
| 419 | ) : null} | |
| 420 | ||
| 421 | {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 422 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | |
| 423 | {props.activePendingApproval ? ( | |
| 424 | <PendingApprovalCard | |
| 425 | approval={props.activePendingApproval} | |
| 426 | respondingApprovalId={props.respondingApprovalId} | |
| 427 | onRespond={props.onRespondToApproval} | |
| 428 | /> | |
| 429 | ) : null} | |
| 430 | {props.activePendingUserInput ? ( | |
| 431 | <PendingUserInputCard | |
| 432 | pendingUserInput={props.activePendingUserInput} | |
| 433 | drafts={props.activePendingUserInputDrafts} | |
| 434 | answers={props.activePendingUserInputAnswers} | |
| 435 | respondingUserInputId={props.respondingUserInputId} | |
| 436 | onSelectOption={props.onSelectUserInputOption} | |
| 437 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 438 | onSubmit={props.onSubmitUserInput} | |
| 439 | /> | |
| 440 | ) : null} | |
| 441 | </View> | |
| 442 | ) : null} | |
| 443 | </View> | |
| 444 | ||
| 445 | <ThreadComposer | |
| 446 | editorRef={composerEditorRef} | |
| 447 | draftMessage={props.draftMessage} | |
| 448 | draftAttachments={props.draftAttachments} | |
| 449 | placeholder="Ask the repo agent, or run a command…" | |
| 407 | 450 | contentMaxWidth={contentMaxWidth} |
| 408 | layoutVariant={layoutVariant} | |
| 409 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | |
| 410 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | |
| 411 | skills={selectedProviderSkills} | |
| 451 | connectionState={props.connectionStateLabel} | |
| 452 | connectionError={props.connectionError} | |
| 453 | environmentLabel={props.environmentLabel} | |
| 454 | threadSyncPhase={threadSyncPhase} | |
| 455 | selectedThread={props.selectedThread} | |
| 456 | serverConfig={props.serverConfig} | |
| 457 | queueCount={props.selectedThreadQueueCount} | |
| 458 | activeThreadBusy={props.activeThreadBusy} | |
| 459 | environmentId={props.environmentId} | |
| 460 | projectCwd={props.projectWorkspaceRoot} | |
| 461 | bottomInset={composerBottomInset} | |
| 462 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 463 | onPickDraftImages={props.onPickDraftImages} | |
| 464 | onNativePasteImages={props.onNativePasteImages} | |
| 465 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 466 | onStopThread={props.onStopThread} | |
| 467 | onSendMessage={handleSendMessage} | |
| 468 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 469 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 470 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 471 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 472 | onExpandedChange={setComposerExpanded} | |
| 412 | 473 | /> |
| 413 | 474 | </View> |
| 414 | ) : ( | |
| 415 | <View style={{ flex: 1 }} /> | |
| 416 | )} | |
| 417 | ||
| 418 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | |
| 419 | {showContent ? ( | |
| 420 | <KeyboardStickyView | |
| 421 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | |
| 422 | offset={{ closed: 0, opened: 0 }} | |
| 423 | > | |
| 424 | <View | |
| 425 | ref={composerOverlayRef} | |
| 426 | onLayout={onComposerLayout} | |
| 427 | style={{ width: "100%", paddingTop: 8 }} | |
| 428 | > | |
| 429 | <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> | |
| 430 | {props.activeWorkStartedAt ? ( | |
| 431 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | |
| 432 | ) : null} | |
| 433 | ||
| 434 | {props.activePendingApproval || props.activePendingUserInput ? ( | |
| 435 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | |
| 436 | {props.activePendingApproval ? ( | |
| 437 | <PendingApprovalCard | |
| 438 | approval={props.activePendingApproval} | |
| 439 | respondingApprovalId={props.respondingApprovalId} | |
| 440 | onRespond={props.onRespondToApproval} | |
| 441 | /> | |
| 442 | ) : null} | |
| 443 | {props.activePendingUserInput ? ( | |
| 444 | <PendingUserInputCard | |
| 445 | pendingUserInput={props.activePendingUserInput} | |
| 446 | drafts={props.activePendingUserInputDrafts} | |
| 447 | answers={props.activePendingUserInputAnswers} | |
| 448 | respondingUserInputId={props.respondingUserInputId} | |
| 449 | onSelectOption={props.onSelectUserInputOption} | |
| 450 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | |
| 451 | onSubmit={props.onSubmitUserInput} | |
| 452 | /> | |
| 453 | ) : null} | |
| 454 | </View> | |
| 455 | ) : null} | |
| 456 | </View> | |
| 457 | ||
| 458 | <ThreadComposer | |
| 459 | editorRef={composerEditorRef} | |
| 460 | draftMessage={props.draftMessage} | |
| 461 | draftAttachments={props.draftAttachments} | |
| 462 | placeholder="Ask the repo agent, or run a command…" | |
| 463 | contentMaxWidth={contentMaxWidth} | |
| 464 | connectionState={props.connectionStateLabel} | |
| 465 | connectionError={props.connectionError} | |
| 466 | environmentLabel={props.environmentLabel} | |
| 467 | threadSyncPhase={threadSyncPhase} | |
| 468 | selectedThread={props.selectedThread} | |
| 469 | serverConfig={props.serverConfig} | |
| 470 | queueCount={props.selectedThreadQueueCount} | |
| 471 | activeThreadBusy={props.activeThreadBusy} | |
| 472 | environmentId={props.environmentId} | |
| 473 | projectCwd={props.projectWorkspaceRoot} | |
| 474 | bottomInset={composerBottomInset} | |
| 475 | onChangeDraftMessage={props.onChangeDraftMessage} | |
| 476 | onPickDraftImages={props.onPickDraftImages} | |
| 477 | onNativePasteImages={props.onNativePasteImages} | |
| 478 | onRemoveDraftImage={props.onRemoveDraftImage} | |
| 479 | onStopThread={props.onStopThread} | |
| 480 | onSendMessage={handleSendMessage} | |
| 481 | onReconnectEnvironment={props.onReconnectEnvironment} | |
| 482 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | |
| 483 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | |
| 484 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | |
| 485 | onExpandedChange={setComposerExpanded} | |
| 486 | /> | |
| 487 | </View> | |
| 488 | </KeyboardStickyView> | |
| 489 | ) : null} | |
| 490 | </View> | |
| 491 | </GestureDetector> | |
| 475 | </KeyboardStickyView> | |
| 476 | ) : null} | |
| 477 | </View> | |
| 492 | 478 | ); |
| 493 | 479 | }); |
apps/mobile/src/features/threads/ThreadFeed.tsx+4| ... | ... | @@ -1508,6 +1508,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1508 | 1508 | // mount positions during attach, where UIKit applies the inset. |
| 1509 | 1509 | key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} |
| 1510 | 1510 | style={{ flex: 1 }} |
| 1511 | // RN 0.81+ drops touches inside the contentInset area | |
| 1512 | // (facebook/react-native#54123); the anchored end space after a send | |
| 1513 | // is pure inset, so without this the blank region can't be scrolled. | |
| 1514 | applyWorkaroundForContentInsetHitTestBug | |
| 1511 | 1515 | contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} |
| 1512 | 1516 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} |
| 1513 | 1517 | {...(usesNativeAutomaticInsets |
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx deleted-255| ... | ... | @@ -1,255 +0,0 @@ |
| 1 | import { SymbolView } from "expo-symbols"; | |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | import { | |
| 4 | type ColorValue, | |
| 5 | Modal, | |
| 6 | Pressable, | |
| 7 | ScrollView, | |
| 8 | useWindowDimensions, | |
| 9 | View, | |
| 10 | } from "react-native"; | |
| 11 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 13 | import Animated, { | |
| 14 | runOnJS, | |
| 15 | useAnimatedStyle, | |
| 16 | useSharedValue, | |
| 17 | withTiming, | |
| 18 | } from "react-native-reanimated"; | |
| 19 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 20 | ||
| 21 | import { AppText as Text } from "../../components/AppText"; | |
| 22 | import { StatusPill } from "../../components/StatusPill"; | |
| 23 | import { useProjects, useThreadShells } from "../../state/entities"; | |
| 24 | import { scopedThreadKey } from "../../lib/scopedEntities"; | |
| 25 | import { relativeTime } from "../../lib/time"; | |
| 26 | import { resolveThreadStatus } from "./threadPresentation"; | |
| 27 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 28 | import { buildThreadNavigationGroups } from "./thread-navigation-groups"; | |
| 29 | ||
| 30 | export function ThreadNavigationDrawer(props: { | |
| 31 | readonly visible: boolean; | |
| 32 | readonly selectedThreadKey: string | null; | |
| 33 | readonly onClose: () => void; | |
| 34 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | |
| 35 | readonly onStartNewTask: () => void; | |
| 36 | }) { | |
| 37 | const insets = useSafeAreaInsets(); | |
| 38 | const { width } = useWindowDimensions(); | |
| 39 | const drawerWidth = Math.min(width * 0.84, 360); | |
| 40 | const [mounted, setMounted] = useState(props.visible); | |
| 41 | const translateX = useSharedValue(-drawerWidth); | |
| 42 | const overlayOpacity = useSharedValue(0); | |
| 43 | ||
| 44 | const backdropColor = useThemeColor("--color-backdrop"); | |
| 45 | const drawerBg = useThemeColor("--color-drawer"); | |
| 46 | const drawerShadow = useThemeColor("--color-drawer-shadow"); | |
| 47 | const primaryForeground = useThemeColor("--color-primary-foreground"); | |
| 48 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | |
| 49 | ||
| 50 | useEffect(() => { | |
| 51 | if (props.visible) { | |
| 52 | setMounted(true); | |
| 53 | translateX.value = withTiming(0, { duration: 240 }); | |
| 54 | overlayOpacity.value = withTiming(1, { duration: 220 }); | |
| 55 | return; | |
| 56 | } | |
| 57 | ||
| 58 | overlayOpacity.value = withTiming(0, { duration: 180 }); | |
| 59 | translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => { | |
| 60 | if (finished) { | |
| 61 | runOnJS(setMounted)(false); | |
| 62 | } | |
| 63 | }); | |
| 64 | }, [drawerWidth, overlayOpacity, props.visible, translateX]); | |
| 65 | ||
| 66 | const closeDrawer = useCallback(() => { | |
| 67 | props.onClose(); | |
| 68 | }, [props]); | |
| 69 | ||
| 70 | const panGesture = useMemo( | |
| 71 | () => | |
| 72 | Gesture.Pan() | |
| 73 | .activeOffsetX([-12, 12]) | |
| 74 | .failOffsetY([-24, 24]) | |
| 75 | .onUpdate((event) => { | |
| 76 | translateX.value = Math.min(0, event.translationX); | |
| 77 | }) | |
| 78 | .onEnd((event) => { | |
| 79 | const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500; | |
| 80 | if (shouldClose) { | |
| 81 | runOnJS(closeDrawer)(); | |
| 82 | return; | |
| 83 | } | |
| 84 | ||
| 85 | translateX.value = withTiming(0, { duration: 180 }); | |
| 86 | }), | |
| 87 | [closeDrawer, drawerWidth, translateX], | |
| 88 | ); | |
| 89 | ||
| 90 | const drawerStyle = useAnimatedStyle(() => ({ | |
| 91 | transform: [{ translateX: translateX.value }], | |
| 92 | })); | |
| 93 | ||
| 94 | const backdropStyle = useAnimatedStyle(() => ({ | |
| 95 | opacity: overlayOpacity.value, | |
| 96 | })); | |
| 97 | ||
| 98 | if (!mounted) { | |
| 99 | return null; | |
| 100 | } | |
| 101 | ||
| 102 | return ( | |
| 103 | <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent> | |
| 104 | <View style={{ flex: 1 }}> | |
| 105 | <Animated.View | |
| 106 | pointerEvents="none" | |
| 107 | style={[ | |
| 108 | { | |
| 109 | position: "absolute", | |
| 110 | left: 0, | |
| 111 | right: 0, | |
| 112 | top: 0, | |
| 113 | bottom: 0, | |
| 114 | backgroundColor: backdropColor, | |
| 115 | }, | |
| 116 | backdropStyle, | |
| 117 | ]} | |
| 118 | /> | |
| 119 | <Pressable | |
| 120 | style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }} | |
| 121 | onPress={props.onClose} | |
| 122 | /> | |
| 123 | ||
| 124 | <GestureDetector gesture={panGesture}> | |
| 125 | <Animated.View | |
| 126 | style={[ | |
| 127 | { | |
| 128 | position: "absolute", | |
| 129 | left: 0, | |
| 130 | top: 0, | |
| 131 | bottom: 0, | |
| 132 | width: drawerWidth, | |
| 133 | backgroundColor: drawerBg, | |
| 134 | paddingTop: insets.top + 10, | |
| 135 | paddingBottom: Math.max(insets.bottom, 18), | |
| 136 | boxShadow: `20px 0 36px ${String(drawerShadow)}`, | |
| 137 | }, | |
| 138 | drawerStyle, | |
| 139 | ]} | |
| 140 | > | |
| 141 | <View className="flex-row items-center justify-between px-4 pb-5"> | |
| 142 | <Text className="text-2xl font-t3-bold">Threads</Text> | |
| 143 | <Pressable | |
| 144 | onPress={() => { | |
| 145 | props.onClose(); | |
| 146 | props.onStartNewTask(); | |
| 147 | }} | |
| 148 | className="h-11 w-11 items-center justify-center rounded-full bg-primary" | |
| 149 | > | |
| 150 | <SymbolView | |
| 151 | name="square.and.pencil" | |
| 152 | size={17} | |
| 153 | tintColor={primaryForeground} | |
| 154 | type="monochrome" | |
| 155 | /> | |
| 156 | </Pressable> | |
| 157 | </View> | |
| 158 | ||
| 159 | <ThreadNavigationDrawerContent | |
| 160 | bottomInset={Math.max(insets.bottom, 18)} | |
| 161 | borderSubtleColor={borderSubtleColor} | |
| 162 | selectedThreadKey={props.selectedThreadKey} | |
| 163 | onClose={props.onClose} | |
| 164 | onSelectThread={props.onSelectThread} | |
| 165 | /> | |
| 166 | </Animated.View> | |
| 167 | </GestureDetector> | |
| 168 | </View> | |
| 169 | </Modal> | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | function ThreadNavigationDrawerContent(props: { | |
| 174 | readonly bottomInset: number; | |
| 175 | readonly borderSubtleColor: ColorValue; | |
| 176 | readonly selectedThreadKey: string | null; | |
| 177 | readonly onClose: () => void; | |
| 178 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | |
| 179 | }) { | |
| 180 | const projects = useProjects(); | |
| 181 | const threads = useThreadShells(); | |
| 182 | const groupedThreads = useMemo( | |
| 183 | () => buildThreadNavigationGroups({ projects, threads }), | |
| 184 | [projects, threads], | |
| 185 | ); | |
| 186 | ||
| 187 | return ( | |
| 188 | <ScrollView | |
| 189 | showsVerticalScrollIndicator={false} | |
| 190 | contentInset={{ bottom: props.bottomInset + 12 }} | |
| 191 | contentContainerStyle={{ | |
| 192 | gap: 20, | |
| 193 | paddingHorizontal: 14, | |
| 194 | }} | |
| 195 | > | |
| 196 | {groupedThreads.map((group) => ( | |
| 197 | <View key={group.key} className="gap-3"> | |
| 198 | <Text | |
| 199 | className="px-1 text-base font-t3-bold text-foreground-muted" | |
| 200 | style={{ letterSpacing: -0.2 }} | |
| 201 | > | |
| 202 | {group.title} | |
| 203 | </Text> | |
| 204 | ||
| 205 | <View className="overflow-hidden rounded-[22px] bg-card"> | |
| 206 | {group.threads.length === 0 ? ( | |
| 207 | <View className="px-4 py-4"> | |
| 208 | <Text className="text-sm font-medium text-foreground-tertiary">No threads yet</Text> | |
| 209 | </View> | |
| 210 | ) : ( | |
| 211 | group.threads.map((thread, index) => { | |
| 212 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | |
| 213 | const selected = props.selectedThreadKey === threadKey; | |
| 214 | const status = resolveThreadStatus(thread); | |
| 215 | ||
| 216 | return ( | |
| 217 | <Pressable | |
| 218 | key={threadKey} | |
| 219 | onPress={() => { | |
| 220 | props.onSelectThread(thread); | |
| 221 | props.onClose(); | |
| 222 | }} | |
| 223 | style={{ | |
| 224 | paddingHorizontal: 16, | |
| 225 | paddingVertical: 15, | |
| 226 | borderTopWidth: index === 0 ? 0 : 1, | |
| 227 | borderTopColor: props.borderSubtleColor, | |
| 228 | backgroundColor: selected ? undefined : "transparent", | |
| 229 | }} | |
| 230 | className={selected ? "bg-subtle" : undefined} | |
| 231 | > | |
| 232 | <View className="flex-row items-start justify-between gap-3"> | |
| 233 | <View className="flex-1 gap-1"> | |
| 234 | <Text className="text-base font-t3-bold" numberOfLines={1}> | |
| 235 | {thread.title} | |
| 236 | </Text> | |
| 237 | <Text | |
| 238 | className="text-sm font-medium text-foreground-muted" | |
| 239 | numberOfLines={1} | |
| 240 | > | |
| 241 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | |
| 242 | </Text> | |
| 243 | </View> | |
| 244 | {status ? <StatusPill {...status} /> : null} | |
| 245 | </View> | |
| 246 | </Pressable> | |
| 247 | ); | |
| 248 | }) | |
| 249 | )} | |
| 250 | </View> | |
| 251 | </View> | |
| 252 | ))} | |
| 253 | </ScrollView> | |
| 254 | ); | |
| 255 | } |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-1| ... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; |
| 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 | 6 | import { LegendList } from "@legendapp/list/react-native"; |
| 4 | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 5 | 8 | import { SymbolView } from "expo-symbols"; |
| ... | ... | @@ -107,6 +110,7 @@ interface ThreadNavigationSidebarProps { |
| 107 | 110 | readonly selectedThreadKey: string | null; |
| 108 | 111 | readonly onOpenSettings: () => void; |
| 109 | 112 | readonly onOpenEnvironmentSettings: () => void; |
| 113 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | |
| 110 | 114 | readonly onSearchQueryChange: (query: string) => void; |
| 111 | 115 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 112 | 116 | readonly onRequestVisibility: () => void; |
| ... | ... | @@ -413,6 +417,11 @@ function ThreadNavigationSidebarPane( |
| 413 | 417 | isFirst={item.isFirst} |
| 414 | 418 | groupKey={item.group.key} |
| 415 | 419 | onGroupAction={updateGroupDisplay} |
| 420 | // Same gating as the compact Home list: aggregated groups have no | |
| 421 | // single target project, and pending-project groups hold a | |
| 422 | // placeholder shell rather than a real project. | |
| 423 | newThreadTarget={item.group.newThreadTarget} | |
| 424 | onNewThread={props.onNewThreadInProject} | |
| 416 | 425 | project={item.group.representative} |
| 417 | 426 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 418 | 427 | title={item.group.title} |
| ... | ... | @@ -480,6 +489,7 @@ function ThreadNavigationSidebarPane( |
| 480 | 489 | handleSwipeableWillOpen, |
| 481 | 490 | openPendingTask, |
| 482 | 491 | projectCwdByKey, |
| 492 | props.onNewThreadInProject, | |
| 483 | 493 | props.selectedThreadKey, |
| 484 | 494 | props.width, |
| 485 | 495 | savedConnectionsById, |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+26-36| ... | ... | @@ -49,7 +49,6 @@ import { |
| 49 | 49 | useThreadGitRightHeaderItems, |
| 50 | 50 | } from "./ThreadGitControls"; |
| 51 | 51 | import { GitOverviewSheet } from "./git/GitOverviewSheet"; |
| 52 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | |
| 53 | 52 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 54 | 53 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| 55 | 54 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; |
| ... | ... | @@ -197,7 +196,6 @@ function ThreadRouteContent( |
| 197 | 196 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); |
| 198 | 197 | const navigation = useNavigation(); |
| 199 | 198 | const params = props.route.params; |
| 200 | const [drawerVisible, setDrawerVisible] = useState(false); | |
| 201 | 199 | const environmentIdRaw = firstRouteParam(params.environmentId); |
| 202 | 200 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 203 | 201 | const threadId = firstRouteParam(params.threadId); |
| ... | ... | @@ -326,18 +324,6 @@ function ThreadRouteContent( |
| 326 | 324 | ); |
| 327 | 325 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); |
| 328 | 326 | |
| 329 | const handleOpenDrawer = useCallback(() => { | |
| 330 | if (!layout.usesSplitView) { | |
| 331 | setDrawerVisible(true); | |
| 332 | } | |
| 333 | }, [layout.usesSplitView]); | |
| 334 | ||
| 335 | useEffect(() => { | |
| 336 | if (layout.usesSplitView) { | |
| 337 | setDrawerVisible(false); | |
| 338 | } | |
| 339 | }, [layout.usesSplitView]); | |
| 340 | ||
| 341 | 327 | const handleOpenGitInspector = useCallback(() => { |
| 342 | 328 | setInspectorSelection({ routeThreadIdentity, mode: "git" }); |
| 343 | 329 | showAuxiliaryPane("inspector"); |
| ... | ... | @@ -655,6 +641,22 @@ function ThreadRouteContent( |
| 655 | 641 | ], |
| 656 | 642 | [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], |
| 657 | 643 | ); |
| 644 | // Deep links / cold starts land with Thread as the ONLY route, where the | |
| 645 | // native back button does not render. Provide an explicit Home escape for | |
| 646 | // that case; when history exists the native back button is used instead. | |
| 647 | const canGoBack = navigation.canGoBack(); | |
| 648 | const compactHomeHeaderItems = useMemo<NativeHeaderItems>( | |
| 649 | () => [ | |
| 650 | withNativeGlassHeaderItem({ | |
| 651 | accessibilityLabel: "Go to threads list", | |
| 652 | icon: { name: "list.bullet", type: "sfSymbol" as const }, | |
| 653 | identifier: "thread-left-home", | |
| 654 | onPress: () => navigation.dispatch(StackActions.replace("Home")), | |
| 655 | type: "button" as const, | |
| 656 | }), | |
| 657 | ], | |
| 658 | [navigation], | |
| 659 | ); | |
| 658 | 660 | |
| 659 | 661 | if (!environmentId || !threadId) { |
| 660 | 662 | return <OpeningThreadLoadingScreen />; |
| ... | ... | @@ -704,7 +706,6 @@ function ThreadRouteContent( |
| 704 | 706 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 705 | 707 | layoutVariant={layout.variant} |
| 706 | 708 | usesAutomaticContentInsets={usesNativeHeaderGlass} |
| 707 | onOpenDrawer={handleOpenDrawer} | |
| 708 | 709 | onOpenConnectionEditor={handleOpenConnectionEditor} |
| 709 | 710 | onChangeDraftMessage={composer.onChangeDraftMessage} |
| 710 | 711 | onPickDraftImages={composer.onPickDraftImages} |
| ... | ... | @@ -722,23 +723,6 @@ function ThreadRouteContent( |
| 722 | 723 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} |
| 723 | 724 | onSubmitUserInput={requests.onSubmitUserInput} |
| 724 | 725 | /> |
| 725 | ||
| 726 | {layout.usesSplitView ? null : ( | |
| 727 | <ThreadNavigationDrawer | |
| 728 | visible={drawerVisible} | |
| 729 | selectedThreadKey={selectedThreadKey} | |
| 730 | onClose={() => setDrawerVisible(false)} | |
| 731 | onSelectThread={(thread) => { | |
| 732 | navigation.dispatch( | |
| 733 | StackActions.replace("Thread", { | |
| 734 | environmentId: String(thread.environmentId), | |
| 735 | threadId: String(thread.id), | |
| 736 | }), | |
| 737 | ); | |
| 738 | }} | |
| 739 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | |
| 740 | /> | |
| 741 | )} | |
| 742 | 726 | </View> |
| 743 | 727 | </> |
| 744 | 728 | ); |
| ... | ... | @@ -757,11 +741,17 @@ function ThreadRouteContent( |
| 757 | 741 | : undefined, |
| 758 | 742 | title: selectedThread.title, |
| 759 | 743 | headerBackVisible: !layout.usesSplitView, |
| 760 | // Compact uses the NATIVE back button (Thread lives flat in the root | |
| 761 | // stack now, so a real previous route exists); only split view needs | |
| 762 | // custom left items. | |
| 744 | // Compact uses the NATIVE back button when a previous route exists; | |
| 745 | // deep links / cold starts get an explicit Home button instead. | |
| 746 | // Split view always uses its custom left items. | |
| 763 | 747 | unstable_headerLeftItems: |
| 764 | Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined, | |
| 748 | Platform.OS === "ios" | |
| 749 | ? layout.usesSplitView | |
| 750 | ? () => splitLeftHeaderItems | |
| 751 | : canGoBack | |
| 752 | ? undefined | |
| 753 | : () => compactHomeHeaderItems | |
| 754 | : undefined, | |
| 765 | 755 | // Search lives in the persistent sidebar, so the split header keeps |
| 766 | 756 | // the git controls on the RIGHT (no center items — center space is |
| 767 | 757 | // reserved for future breadcrumbs/status). |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+86-38| ... | ... | @@ -231,40 +231,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 231 | 231 | } |
| 232 | 232 | }, []); |
| 233 | 233 | |
| 234 | const environments = useMemo( | |
| 235 | () => | |
| 236 | pipe( | |
| 237 | [ | |
| 238 | ...new Set( | |
| 239 | pipe( | |
| 240 | projects, | |
| 241 | Arr.map((project) => project.environmentId), | |
| 242 | ), | |
| 243 | ), | |
| 244 | ], | |
| 245 | Arr.map((environmentId) => { | |
| 246 | const environment = savedConnectionsById[environmentId]; | |
| 247 | if (!environment) { | |
| 248 | return null; | |
| 249 | } | |
| 250 | ||
| 251 | return { | |
| 252 | environmentId, | |
| 253 | environmentLabel: environment.environmentLabel, | |
| 254 | }; | |
| 255 | }), | |
| 256 | Arr.filter( | |
| 257 | ( | |
| 258 | entry, | |
| 259 | ): entry is { | |
| 260 | readonly environmentId: EnvironmentId; | |
| 261 | readonly environmentLabel: string; | |
| 262 | } => entry !== null, | |
| 263 | ), | |
| 264 | ), | |
| 265 | [projects, savedConnectionsById], | |
| 266 | ); | |
| 267 | ||
| 268 | 234 | const projectsForEnvironment = useMemo( |
| 269 | 235 | () => |
| 270 | 236 | pipe( |
| ... | ... | @@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 310 | 276 | scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) |
| 311 | 277 | ? editingPendingProject |
| 312 | 278 | : (projectsForEnvironment[0] ?? null)); |
| 279 | ||
| 280 | // Only offer machines that actually host the currently selected repository, so | |
| 281 | // switching computers moves the same repo across machines instead of jumping to | |
| 282 | // whatever unrelated project happens to be first on the other machine. Repository | |
| 283 | // identity is the primary signal; projects that haven't reported one yet (still | |
| 284 | // indexing) fall back to workspace basename / title so a valid host isn't hidden. | |
| 285 | const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | |
| 286 | // `|| null` (not `??`): a pending-task placeholder project can have an empty | |
| 287 | // workspaceRoot, and an "" basename would reject every real host below. | |
| 288 | const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | |
| 289 | const selectedProjectTitle = selectedProject?.title ?? null; | |
| 290 | const environments = useMemo(() => { | |
| 291 | const seen = new Set<EnvironmentId>(); | |
| 292 | const result: Array<{ | |
| 293 | readonly environmentId: EnvironmentId; | |
| 294 | readonly environmentLabel: string; | |
| 295 | }> = []; | |
| 296 | const hostsSelectedRepository = (project: EnvironmentProject) => { | |
| 297 | if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { | |
| 298 | return true; | |
| 299 | } | |
| 300 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | |
| 301 | if (selectedRepositoryKey !== null && projectKey !== null) { | |
| 302 | return projectKey === selectedRepositoryKey; | |
| 303 | } | |
| 304 | return ( | |
| 305 | project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || | |
| 306 | (selectedProjectTitle !== null && project.title === selectedProjectTitle) | |
| 307 | ); | |
| 308 | }; | |
| 309 | for (const project of projects) { | |
| 310 | if (!hostsSelectedRepository(project)) { | |
| 311 | continue; | |
| 312 | } | |
| 313 | if (seen.has(project.environmentId)) { | |
| 314 | continue; | |
| 315 | } | |
| 316 | const environment = savedConnectionsById[project.environmentId]; | |
| 317 | if (!environment) { | |
| 318 | continue; | |
| 319 | } | |
| 320 | seen.add(project.environmentId); | |
| 321 | result.push({ | |
| 322 | environmentId: project.environmentId, | |
| 323 | environmentLabel: environment.environmentLabel, | |
| 324 | }); | |
| 325 | } | |
| 326 | return result; | |
| 327 | }, [ | |
| 328 | projects, | |
| 329 | savedConnectionsById, | |
| 330 | selectedRepositoryKey, | |
| 331 | selectedWorkspaceBasename, | |
| 332 | selectedProjectTitle, | |
| 333 | ]); | |
| 334 | ||
| 313 | 335 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| 314 | 336 | selectedProject?.environmentId ?? null, |
| 315 | 337 | ); |
| ... | ... | @@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 480 | 502 | setSelectedProjectKey(nextProjectKey); |
| 481 | 503 | }, []); |
| 482 | 504 | |
| 483 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { | |
| 484 | setSelectedEnvironmentId(environmentId); | |
| 485 | setSelectedProjectKey(null); | |
| 486 | }, []); | |
| 505 | const selectEnvironment = useCallback( | |
| 506 | (environmentId: EnvironmentId) => { | |
| 507 | const projectsOnTarget = projects.filter( | |
| 508 | (project) => project.environmentId === environmentId, | |
| 509 | ); | |
| 510 | const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | |
| 511 | // Prefer the repository identity; projects without one (e.g. not yet | |
| 512 | // indexed) fall back to workspace basename, then title, so switching | |
| 513 | // computers still follows the same repo instead of resetting to | |
| 514 | // whatever project is first on the target machine. | |
| 515 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | |
| 516 | const match = | |
| 517 | (repositoryKey !== null | |
| 518 | ? projectsOnTarget.find( | |
| 519 | (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, | |
| 520 | ) | |
| 521 | : undefined) ?? | |
| 522 | (workspaceBasename !== null | |
| 523 | ? projectsOnTarget.find( | |
| 524 | (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, | |
| 525 | ) | |
| 526 | : undefined) ?? | |
| 527 | (selectedProject !== null | |
| 528 | ? projectsOnTarget.find((project) => project.title === selectedProject.title) | |
| 529 | : undefined); | |
| 530 | setSelectedEnvironmentId(environmentId); | |
| 531 | setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); | |
| 532 | }, | |
| 533 | [projects, selectedProject], | |
| 534 | ); | |
| 487 | 535 | |
| 488 | 536 | const setWorkspaceMode = useCallback( |
| 489 | 537 | (mode: WorkspaceMode) => { |
apps/mobile/src/features/threads/thread-list-items.tsx+56-23| ... | ... | @@ -43,31 +43,54 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 43 | 43 | readonly isFirst: boolean; |
| 44 | 44 | readonly groupKey: string; |
| 45 | 45 | readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; |
| 46 | /** Project a quick new thread should target; null hides the button. */ | |
| 47 | readonly newThreadTarget?: EnvironmentProject | null; | |
| 48 | readonly onNewThread?: (project: EnvironmentProject) => void; | |
| 46 | 49 | }) { |
| 47 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | |
| 48 | const { groupKey, onGroupAction } = props; | |
| 50 | const iconMutedColor = useThemeColor("--color-icon-muted"); | |
| 51 | const { groupKey, onGroupAction, onNewThread } = props; | |
| 52 | const newThreadTarget = props.newThreadTarget ?? null; | |
| 49 | 53 | const compact = props.variant === "compact"; |
| 50 | 54 | const handleToggle = useCallback( |
| 51 | 55 | () => onGroupAction(groupKey, "toggle-collapsed"), |
| 52 | 56 | [groupKey, onGroupAction], |
| 53 | 57 | ); |
| 58 | const handleNewThread = useCallback(() => { | |
| 59 | if (newThreadTarget) { | |
| 60 | onNewThread?.(newThreadTarget); | |
| 61 | } | |
| 62 | }, [newThreadTarget, onNewThread]); | |
| 63 | const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; | |
| 54 | 64 | |
| 65 | // The new-thread button is a SIBLING of the collapse toggle, not a child: | |
| 66 | // nested touchables are unreachable to VoiceOver/TalkBack (the parent | |
| 67 | // swallows focus). Row padding lives on the container (explicit styles — | |
| 68 | // dynamic padding classes on Pressable did not apply reliably) so both | |
| 69 | // children share one centerline; hitSlop restores the padded tap area. | |
| 70 | const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; | |
| 55 | 71 | return ( |
| 56 | <Pressable | |
| 57 | accessibilityRole="button" | |
| 58 | accessibilityState={{ expanded: !props.collapsed }} | |
| 59 | accessibilityLabel={`${props.title}, ${props.threadCount} threads`} | |
| 60 | accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} | |
| 61 | className={compact ? "bg-screen" : undefined} | |
| 62 | onPress={handleToggle} | |
| 72 | <View | |
| 73 | className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"} | |
| 74 | style={{ | |
| 75 | minHeight: compact ? 44 : 36, | |
| 76 | paddingLeft: compact ? 20 : 12, | |
| 77 | // Compact right padding centers the 20pt plus glyph on the thread | |
| 78 | // rows' trailing chevron column (18 + 13/2 ≈ 24.5 from the edge). | |
| 79 | paddingRight: compact ? 14 : 12, | |
| 80 | paddingBottom: compact ? 12 : 8, | |
| 81 | paddingTop: props.isFirst ? (compact ? 8 : 4) : compact ? 24 : 20, | |
| 82 | }} | |
| 63 | 83 | > |
| 64 | <View | |
| 84 | <Pressable | |
| 85 | accessibilityRole="button" | |
| 86 | accessibilityState={{ expanded: !props.collapsed }} | |
| 87 | accessibilityLabel={`${props.title}, ${props.threadCount} threads`} | |
| 88 | accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} | |
| 65 | 89 | className={ |
| 66 | compact | |
| 67 | ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}` | |
| 68 | : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}` | |
| 90 | compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2" | |
| 69 | 91 | } |
| 70 | style={{ minHeight: compact ? 44 : 36 }} | |
| 92 | hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }} | |
| 93 | onPress={handleToggle} | |
| 71 | 94 | > |
| 72 | 95 | <ProjectFavicon |
| 73 | 96 | environmentId={props.project.environmentId} |
| ... | ... | @@ -95,15 +118,25 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 95 | 118 | > |
| 96 | 119 | {props.threadCount} |
| 97 | 120 | </Text> |
| 98 | <SymbolView | |
| 99 | name={props.collapsed ? "chevron.right" : "chevron.down"} | |
| 100 | size={compact ? 13 : 11} | |
| 101 | tintColor={iconSubtleColor} | |
| 102 | type="monochrome" | |
| 103 | weight="semibold" | |
| 104 | /> | |
| 105 | </View> | |
| 106 | </Pressable> | |
| 121 | </Pressable> | |
| 122 | {showNewThreadButton ? ( | |
| 123 | <Pressable | |
| 124 | accessibilityLabel={`Create new thread in ${props.title}`} | |
| 125 | accessibilityRole="button" | |
| 126 | hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }} | |
| 127 | onPress={handleNewThread} | |
| 128 | style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} | |
| 129 | > | |
| 130 | <SymbolView | |
| 131 | name="plus" | |
| 132 | size={compact ? 20 : 16} | |
| 133 | tintColor={iconMutedColor} | |
| 134 | type="monochrome" | |
| 135 | weight="medium" | |
| 136 | /> | |
| 137 | </Pressable> | |
| 138 | ) : null} | |
| 139 | </View> | |
| 107 | 140 | ); |
| 108 | 141 | }); |
| 109 | 142 |
apps/mobile/src/features/threads/thread-navigation-groups.test.ts deleted-111| ... | ... | @@ -1,111 +0,0 @@ |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { buildThreadNavigationGroups } from "./thread-navigation-groups"; | |
| 9 | ||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | |
| 11 | ||
| 12 | function makeProject(input: Pick<EnvironmentProject, "id" | "title">): EnvironmentProject { | |
| 13 | return { | |
| 14 | environmentId, | |
| 15 | workspaceRoot: `/workspaces/${input.id}`, | |
| 16 | repositoryIdentity: null, | |
| 17 | defaultModelSelection: null, | |
| 18 | scripts: [], | |
| 19 | createdAt: "2026-06-01T00:00:00.000Z", | |
| 20 | updatedAt: "2026-06-01T00:00:00.000Z", | |
| 21 | ...input, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | function makeThread( | |
| 26 | input: Pick<EnvironmentThreadShell, "id" | "projectId" | "title"> & | |
| 27 | Partial<EnvironmentThreadShell>, | |
| 28 | ): EnvironmentThreadShell { | |
| 29 | return { | |
| 30 | environmentId, | |
| 31 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | |
| 32 | runtimeMode: "full-access", | |
| 33 | interactionMode: "default", | |
| 34 | branch: null, | |
| 35 | worktreePath: null, | |
| 36 | latestTurn: null, | |
| 37 | createdAt: "2026-06-01T00:00:00.000Z", | |
| 38 | updatedAt: "2026-06-01T00:00:00.000Z", | |
| 39 | archivedAt: null, | |
| 40 | session: null, | |
| 41 | latestUserMessageAt: null, | |
| 42 | hasPendingApprovals: false, | |
| 43 | hasPendingUserInput: false, | |
| 44 | hasActionableProposedPlan: false, | |
| 45 | ...input, | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | describe("buildThreadNavigationGroups", () => { | |
| 50 | const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); | |
| 51 | const threads = [ | |
| 52 | makeThread({ | |
| 53 | id: ThreadId.make("older"), | |
| 54 | projectId: project.id, | |
| 55 | title: "Fix reconnect flow", | |
| 56 | updatedAt: "2026-06-02T00:00:00.000Z", | |
| 57 | }), | |
| 58 | makeThread({ | |
| 59 | id: ThreadId.make("newer"), | |
| 60 | projectId: project.id, | |
| 61 | title: "Build adaptive sidebar", | |
| 62 | updatedAt: "2026-06-03T00:00:00.000Z", | |
| 63 | }), | |
| 64 | ]; | |
| 65 | ||
| 66 | it("sorts each group by recent activity", () => { | |
| 67 | expect( | |
| 68 | buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map( | |
| 69 | (thread) => thread.id, | |
| 70 | ), | |
| 71 | ).toEqual(["newer", "older"]); | |
| 72 | }); | |
| 73 | ||
| 74 | it("matches thread titles without dropping their group", () => { | |
| 75 | const groups = buildThreadNavigationGroups({ | |
| 76 | projects: [project], | |
| 77 | threads, | |
| 78 | searchQuery: "reconnect", | |
| 79 | }); | |
| 80 | ||
| 81 | expect(groups).toHaveLength(1); | |
| 82 | expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]); | |
| 83 | }); | |
| 84 | ||
| 85 | it("keeps every thread when the project title matches", () => { | |
| 86 | expect( | |
| 87 | buildThreadNavigationGroups({ | |
| 88 | projects: [project], | |
| 89 | threads, | |
| 90 | searchQuery: "t3 code", | |
| 91 | })[0]?.threads.map((thread) => thread.id), | |
| 92 | ).toEqual(["newer", "older"]); | |
| 93 | }); | |
| 94 | ||
| 95 | it("excludes archived threads from the navigation sidebar", () => { | |
| 96 | const archived = makeThread({ | |
| 97 | id: ThreadId.make("archived"), | |
| 98 | projectId: project.id, | |
| 99 | title: "Archived work", | |
| 100 | archivedAt: "2026-06-04T00:00:00.000Z", | |
| 101 | updatedAt: "2026-06-04T00:00:00.000Z", | |
| 102 | }); | |
| 103 | ||
| 104 | expect( | |
| 105 | buildThreadNavigationGroups({ | |
| 106 | projects: [project], | |
| 107 | threads: [...threads, archived], | |
| 108 | })[0]?.threads.map((thread) => thread.id), | |
| 109 | ).toEqual(["newer", "older"]); | |
| 110 | }); | |
| 111 | }); |
apps/mobile/src/features/threads/thread-navigation-groups.ts deleted-64| ... | ... | @@ -1,64 +0,0 @@ |
| 1 | import type { | |
| 2 | EnvironmentProject, | |
| 3 | EnvironmentThreadShell, | |
| 4 | } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import * as Arr from "effect/Array"; | |
| 6 | import * as Order from "effect/Order"; | |
| 7 | ||
| 8 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | |
| 9 | ||
| 10 | export interface ThreadNavigationGroup { | |
| 11 | readonly key: string; | |
| 12 | readonly title: string; | |
| 13 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | |
| 14 | } | |
| 15 | ||
| 16 | const threadActivityOrder = Order.mapInput( | |
| 17 | Order.Struct({ | |
| 18 | activityAt: Order.flip(Order.Number), | |
| 19 | title: Order.String, | |
| 20 | }), | |
| 21 | (thread: EnvironmentThreadShell) => ({ | |
| 22 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), | |
| 23 | title: thread.title, | |
| 24 | }), | |
| 25 | ); | |
| 26 | ||
| 27 | export function buildThreadNavigationGroups(input: { | |
| 28 | readonly projects: ReadonlyArray<EnvironmentProject>; | |
| 29 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | |
| 30 | readonly searchQuery?: string; | |
| 31 | }): ReadonlyArray<ThreadNavigationGroup> { | |
| 32 | const query = input.searchQuery?.trim().toLocaleLowerCase() ?? ""; | |
| 33 | const activeThreads = input.threads.filter((thread) => thread.archivedAt === null); | |
| 34 | ||
| 35 | return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => { | |
| 36 | const threads = Arr.sort( | |
| 37 | group.projects.flatMap((projectGroup) => projectGroup.threads), | |
| 38 | threadActivityOrder, | |
| 39 | ); | |
| 40 | const title = group.projects[0]?.project.title ?? group.title; | |
| 41 | const groupMatches = | |
| 42 | query.length === 0 || | |
| 43 | title.toLocaleLowerCase().includes(query) || | |
| 44 | group.title.toLocaleLowerCase().includes(query) || | |
| 45 | group.projects.some((projectGroup) => | |
| 46 | projectGroup.project.title.toLocaleLowerCase().includes(query), | |
| 47 | ); | |
| 48 | const matchingThreads = groupMatches | |
| 49 | ? threads | |
| 50 | : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); | |
| 51 | ||
| 52 | if (query.length > 0 && matchingThreads.length === 0) { | |
| 53 | return []; | |
| 54 | } | |
| 55 | ||
| 56 | return [ | |
| 57 | { | |
| 58 | key: group.key, | |
| 59 | title, | |
| 60 | threads: matchingThreads, | |
| 61 | }, | |
| 62 | ]; | |
| 63 | }); | |
| 64 | } |
apps/mobile/src/lib/time.ts+4-3| ... | ... | @@ -1,12 +1,13 @@ |
| 1 | 1 | export function relativeTime(input: string): string { |
| 2 | 2 | const timestamp = Date.parse(input); |
| 3 | 3 | if (Number.isNaN(timestamp)) { |
| 4 | return "now"; | |
| 4 | return "<1m"; | |
| 5 | 5 | } |
| 6 | 6 | |
| 7 | // Anything under a minute renders as "<1m" rather than a live seconds count. | |
| 8 | // The seconds ticker changed width every second and reflowed the surrounding row. | |
| 7 | 9 | const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); |
| 8 | if (deltaSeconds < 10) return "now"; | |
| 9 | if (deltaSeconds < 60) return `${deltaSeconds}s`; | |
| 10 | if (deltaSeconds < 60) return "<1m"; | |
| 10 | 11 | |
| 11 | 12 | const deltaMinutes = Math.floor(deltaSeconds / 60); |
| 12 | 13 | if (deltaMinutes < 60) return `${deltaMinutes}m`; |
pnpm-lock.yaml+5-5| ... | ... | @@ -378,8 +378,8 @@ importers: |
| 378 | 378 | specifier: ^0.2.2 |
| 379 | 379 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 380 | 380 | react-native-keyboard-controller: |
| 381 | specifier: 1.21.6 | |
| 382 | version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 381 | specifier: 1.21.13 | |
| 382 | version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 383 | 383 | react-native-nitro-markdown: |
| 384 | 384 | specifier: ^0.5.0 |
| 385 | 385 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | ... | @@ -8814,8 +8814,8 @@ packages: |
| 8814 | 8814 | react: '*' |
| 8815 | 8815 | react-native: '*' |
| 8816 | 8816 | |
| 8817 | react-native-keyboard-controller@1.21.6: | |
| 8818 | resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} | |
| 8817 | react-native-keyboard-controller@1.21.13: | |
| 8818 | resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==} | |
| 8819 | 8819 | peerDependencies: |
| 8820 | 8820 | react: '*' |
| 8821 | 8821 | react-native: '*' |
| ... | ... | @@ -18950,7 +18950,7 @@ snapshots: |
| 18950 | 18950 | react: 19.2.3 |
| 18951 | 18951 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18952 | 18952 | |
| 18953 | react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | |
| 18953 | react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | |
| 18954 | 18954 | dependencies: |
| 18955 | 18955 | react: 19.2.3 |
| 18956 | 18956 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |