| 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,7 +96,7 @@ |
| 96 | "react-native": "0.85.3", | 96 | "react-native": "0.85.3", |
| 97 | "react-native-gesture-handler": "~2.31.1", | 97 | "react-native-gesture-handler": "~2.31.1", |
| 98 | "react-native-image-viewing": "^0.2.2", | 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 | "react-native-nitro-markdown": "^0.5.0", | 100 | "react-native-nitro-markdown": "^0.5.0", |
| 101 | "react-native-nitro-modules": "0.35.9", | 101 | "react-native-nitro-modules": "0.35.9", |
| 102 | "react-native-reanimated": "4.3.1", | 102 | "react-native-reanimated": "4.3.1", |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1-1| ... | @@ -333,7 +333,7 @@ function ArchivedThreadRow(props: { | ... | @@ -333,7 +333,7 @@ function ArchivedThreadRow(props: { |
| 333 | </Text> | 333 | </Text> |
| 334 | <Text | 334 | <Text |
| 335 | className="text-xs text-foreground-tertiary" | 335 | className="text-xs text-foreground-tertiary" |
| 336 | style={{ fontVariant: ["tabular-nums"] }} | 336 | style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }} |
| 337 | > | 337 | > |
| 338 | {timestamp} | 338 | {timestamp} |
| 339 | </Text> | 339 | </Text> |
apps/mobile/src/features/home/HomeRouteScreen.tsx+10| ... | @@ -122,6 +122,16 @@ export function HomeRouteScreen() { | ... | @@ -122,6 +122,16 @@ export function HomeRouteScreen() { |
| 122 | }} | 122 | }} |
| 123 | onSelectPendingTask={openPendingTask} | 123 | onSelectPendingTask={openPendingTask} |
| 124 | onDeletePendingTask={confirmDeletePendingTask} | 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 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 135 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 126 | onThreadSortOrderChange={setThreadSortOrder} | 136 | onThreadSortOrderChange={setThreadSortOrder} |
| 127 | pendingTasks={pendingTasks} | 137 | pendingTasks={pendingTasks} |
apps/mobile/src/features/home/HomeScreen.tsx+8| ... | @@ -72,6 +72,7 @@ interface HomeScreenProps { | ... | @@ -72,6 +72,7 @@ interface HomeScreenProps { |
| 72 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; | 72 | readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; |
| 73 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; | 73 | readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; |
| 74 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; | 74 | readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; |
| 75 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | ||
| 75 | } | 76 | } |
| 76 | 77 | ||
| 77 | /* ─── Layout constants ───────────────────────────────────────────────── */ | 78 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| ... | @@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) { |
| 248 | isFirst={item.isFirst} | 249 | isFirst={item.isFirst} |
| 249 | groupKey={item.group.key} | 250 | groupKey={item.group.key} |
| 250 | onGroupAction={updateGroupDisplay} | 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 | project={item.group.representative} | 258 | project={item.group.representative} |
| 252 | threadCount={item.group.threads.length + item.group.pendingTasks.length} | 259 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 253 | title={item.group.title} | 260 | title={item.group.title} |
| ... | @@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 308 | props.onArchiveThread, | 315 | props.onArchiveThread, |
| 309 | props.onDeletePendingTask, | 316 | props.onDeletePendingTask, |
| 310 | props.onDeleteThread, | 317 | props.onDeleteThread, |
| 318 | props.onNewThreadInProject, | ||
| 311 | props.onSelectPendingTask, | 319 | props.onSelectPendingTask, |
| 312 | props.onSelectThread, | 320 | props.onSelectThread, |
| 313 | props.savedConnectionsById, | 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,15 +57,20 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { |
| 57 | 57 | ||
| 58 | function makeGroup(key: string, threadCount: number): HomeThreadGroup { | 58 | function makeGroup(key: string, threadCount: number): HomeThreadGroup { |
| 59 | const project = makeProject(key, key); | 59 | const project = makeProject(key, key); |
| 60 | const threads = Array.from({ length: threadCount }, (_, index) => | ||
| 61 | makeThread(`${key}-thread-${index}`, project.id), | ||
| 62 | ); | ||
| 60 | return { | 63 | return { |
| 61 | key, | 64 | key, |
| 62 | title: key, | 65 | title: key, |
| 63 | representative: project, | 66 | representative: project, |
| 64 | projects: [project], | 67 | projects: [project], |
| 65 | pendingTasks: [], | 68 | pendingTasks: [], |
| 66 | threads: Array.from({ length: threadCount }, (_, index) => | 69 | threads, |
| 67 | makeThread(`${key}-thread-${index}`, project.id), | 70 | // All threads inside the recency window, so the baseline stays at the |
| 68 | ), | 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,6 +158,48 @@ describe("buildHomeListLayout", () => { |
| 153 | expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); | 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 | it("hides threads and the show-more row for collapsed groups", () => { | 203 | it("hides threads and the show-more row for collapsed groups", () => { |
| 157 | const layout = buildHomeListLayout({ | 204 | const layout = buildHomeListLayout({ |
| 158 | groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], | 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,12 +145,27 @@ export function buildHomeListLayout(input: { |
| 145 | } | 145 | } |
| 146 | 146 | ||
| 147 | const totalCount = group.threads.length; | 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 | const visibleCount = input.showAllThreads | 158 | const visibleCount = input.showAllThreads |
| 149 | ? totalCount | 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 | const visibleThreads = group.threads.slice(0, visibleCount); | 166 | const visibleThreads = group.threads.slice(0, visibleCount); |
| 152 | const hiddenCount = totalCount - visibleCount; | 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 | // Pending (unsent) tasks lead the group and are never paginated away. | 170 | // Pending (unsent) tasks lead the group and are never paginated away. |
| 156 | for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { | 171 | for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { |
| ... | @@ -180,7 +195,10 @@ export function buildHomeListLayout(input: { | ... | @@ -180,7 +195,10 @@ export function buildHomeListLayout(input: { |
| 180 | key: `show-more:${group.key}`, | 195 | key: `show-more:${group.key}`, |
| 181 | groupKey: group.key, | 196 | groupKey: group.key, |
| 182 | hiddenCount, | 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,6 +44,8 @@ function makeThread( |
| 44 | }; | 44 | }; |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | const NOW = Date.parse("2026-06-29T00:00:00.000Z"); | ||
| 48 | |||
| 47 | function buildGroups( | 49 | function buildGroups( |
| 48 | projects: ReadonlyArray<EnvironmentProject>, | 50 | projects: ReadonlyArray<EnvironmentProject>, |
| 49 | threads: ReadonlyArray<EnvironmentThreadShell>, | 51 | threads: ReadonlyArray<EnvironmentThreadShell>, |
| ... | @@ -57,6 +59,7 @@ function buildGroups( | ... | @@ -57,6 +59,7 @@ function buildGroups( |
| 57 | projectSortOrder: "updated_at", | 59 | projectSortOrder: "updated_at", |
| 58 | threadSortOrder: "updated_at", | 60 | threadSortOrder: "updated_at", |
| 59 | projectGroupingMode: "repository", | 61 | projectGroupingMode: "repository", |
| 62 | now: NOW, | ||
| 60 | ...overrides, | 63 | ...overrides, |
| 61 | }); | 64 | }); |
| 62 | } | 65 | } |
| ... | @@ -220,4 +223,144 @@ describe("buildHomeThreadGroups", () => { | ... | @@ -220,4 +223,144 @@ describe("buildHomeThreadGroups", () => { |
| 220 | ); | 223 | ); |
| 221 | expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); | 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,6 +14,7 @@ import type { |
| 14 | SidebarThreadSortOrder, | 14 | SidebarThreadSortOrder, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import * as Arr from "effect/Array"; | 16 | import * as Arr from "effect/Array"; |
| 17 | import * as Option from "effect/Option"; | ||
| 17 | import * as Order from "effect/Order"; | 18 | import * as Order from "effect/Order"; |
| 18 | 19 | ||
| 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 20 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| ... | @@ -21,13 +22,33 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | ... | @@ -21,13 +22,33 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 21 | 22 | ||
| 22 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; | 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 | export interface HomeThreadGroup { | 33 | export interface HomeThreadGroup { |
| 25 | readonly key: string; | 34 | readonly key: string; |
| 26 | readonly title: string; | 35 | readonly title: string; |
| 27 | readonly representative: EnvironmentProject; | 36 | readonly representative: EnvironmentProject; |
| 28 | readonly projects: ReadonlyArray<EnvironmentProject>; | 37 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 29 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; | 38 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 39 | /** Full sorted thread history for the group (revealed when expanded / searching). */ | ||
| 30 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 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 | interface MutableHomeThreadGroup { | 54 | interface MutableHomeThreadGroup { |
| ... | @@ -48,6 +69,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr | ... | @@ -48,6 +69,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr |
| 48 | }, latestThread); | 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 | export function buildHomeThreadGroups(input: { | 90 | export function buildHomeThreadGroups(input: { |
| 52 | readonly projects: ReadonlyArray<EnvironmentProject>; | 91 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 53 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | 92 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| ... | @@ -57,7 +96,10 @@ export function buildHomeThreadGroups(input: { | ... | @@ -57,7 +96,10 @@ export function buildHomeThreadGroups(input: { |
| 57 | readonly projectSortOrder: HomeProjectSortOrder; | 96 | readonly projectSortOrder: HomeProjectSortOrder; |
| 58 | readonly threadSortOrder: SidebarThreadSortOrder; | 97 | readonly threadSortOrder: SidebarThreadSortOrder; |
| 59 | readonly projectGroupingMode: SidebarProjectGroupingMode; | 98 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 99 | /** Current time used for the recency window; defaults to now. Injectable for tests. */ | ||
| 100 | readonly now?: number; | ||
| 60 | }): ReadonlyArray<HomeThreadGroup> { | 101 | }): ReadonlyArray<HomeThreadGroup> { |
| 102 | const now = input.now ?? Date.now(); | ||
| 61 | const groups = new Map<string, MutableHomeThreadGroup>(); | 103 | const groups = new Map<string, MutableHomeThreadGroup>(); |
| 62 | const groupKeyByProjectKey = new Map<string, string>(); | 104 | const groupKeyByProjectKey = new Map<string, string>(); |
| 63 | 105 | ||
| ... | @@ -165,13 +207,43 @@ export function buildHomeThreadGroups(input: { | ... | @@ -165,13 +207,43 @@ export function buildHomeThreadGroups(input: { |
| 165 | continue; | 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 | result.push({ | 236 | result.push({ |
| 169 | key: group.key, | 237 | key: group.key, |
| 170 | title, | 238 | title, |
| 171 | representative, | 239 | representative, |
| 172 | projects: group.projects, | 240 | projects: group.projects, |
| 173 | pendingTasks: matchingPendingTasks, | 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,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 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 5 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useFocusEffect } from "@react-navigation/native"; | 6 | import { useFocusEffect } from "@react-navigation/native"; |
| 4 | import { | 7 | import { |
| ... | @@ -401,6 +404,20 @@ export function AdaptiveWorkspaceLayout(props: { | ... | @@ -401,6 +404,20 @@ export function AdaptiveWorkspaceLayout(props: { |
| 401 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); | 404 | navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); |
| 402 | }, [navigation]); | 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 | const renderedSidebarWidth = useSharedValue( | 421 | const renderedSidebarWidth = useSharedValue( |
| 405 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, | 422 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0, |
| 406 | ); | 423 | ); |
| ... | @@ -477,6 +494,7 @@ export function AdaptiveWorkspaceLayout(props: { | ... | @@ -477,6 +494,7 @@ export function AdaptiveWorkspaceLayout(props: { |
| 477 | selectedThreadKey={selectedThreadKey} | 494 | selectedThreadKey={selectedThreadKey} |
| 478 | onOpenSettings={handleOpenSettings} | 495 | onOpenSettings={handleOpenSettings} |
| 479 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} | 496 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} |
| 497 | onNewThreadInProject={handleNewThreadInProject} | ||
| 480 | onSelectThread={handleSelectThread} | 498 | onSelectThread={handleSelectThread} |
| 481 | onSearchQueryChange={setPrimarySidebarSearchQuery} | 499 | onSearchQueryChange={setPrimarySidebarSearchQuery} |
| 482 | searchQuery={primarySidebarSearchQuery} | 500 | searchQuery={primarySidebarSearchQuery} |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+6-5| ... | @@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: { |
| 111 | }, [props.pendingTaskId, cancelEditingPendingTask]); | 111 | }, [props.pendingTaskId, cancelEditingPendingTask]); |
| 112 | 112 | ||
| 113 | const borderColor = useThemeColor("--color-border"); | 113 | const borderColor = useThemeColor("--color-border"); |
| 114 | const bodyText = useScaledTextRole("body"); | 114 | const headlineText = useScaledTextRole("headline"); |
| 115 | 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)"; |
| 116 | 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)"; |
| 117 | 117 | ||
| ... | @@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: { | ... | @@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: { |
| 129 | lastInitialProjectRefRef.current = props.initialProjectRef; | 129 | lastInitialProjectRefRef.current = props.initialProjectRef; |
| 130 | appliedInitialProjectKeyRef.current = null; | 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 | const directProject = | 135 | const directProject = |
| 134 | projects.find( | 136 | projects.find( |
| 135 | (project) => | 137 | (project) => |
| 136 | project.environmentId === props.initialProjectRef?.environmentId && | 138 | project.environmentId === initialEnvironmentId && project.id === initialProjectId, |
| 137 | project.id === props.initialProjectRef?.projectId, | ||
| 138 | ) ?? null; | 139 | ) ?? null; |
| 139 | 140 | ||
| 140 | if (directProject) { | 141 | if (directProject) { |
| ... | @@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: { |
| 602 | onPasteImages={(uris) => void handleNativePasteImages(uris)} | 603 | onPasteImages={(uris) => void handleNativePasteImages(uris)} |
| 603 | placeholder={`Describe a coding task in ${selectedProject.title}`} | 604 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 604 | style={{ flex: 1, minHeight: 0 }} | 605 | style={{ flex: 1, minHeight: 0 }} |
| 605 | textStyle={bodyText} | 606 | textStyle={headlineText} |
| 606 | /> | 607 | /> |
| 607 | </View> | 608 | </View> |
| 608 | 609 |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+129-143| ... | @@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | ... | @@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 18 | import * as Haptics from "expo-haptics"; | 18 | import * as Haptics from "expo-haptics"; |
| 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | import { View, type GestureResponderEvent } from "react-native"; | 20 | import { View, type GestureResponderEvent } from "react-native"; |
| 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 21 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 22 | import { KeyboardStickyView } from "react-native-keyboard-controller"; | ||
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | import { runOnJS } from "react-native-reanimated"; | ||
| 25 | 23 | ||
| 26 | import { AppText as Text } from "../../components/AppText"; | 24 | import { AppText as Text } from "../../components/AppText"; |
| 27 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 25 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| ... | @@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps { | ... | @@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps { |
| 73 | readonly layoutVariant?: LayoutVariant; | 71 | readonly layoutVariant?: LayoutVariant; |
| 74 | readonly usesAutomaticContentInsets?: boolean; | 72 | readonly usesAutomaticContentInsets?: boolean; |
| 75 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; | 73 | readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; |
| 76 | readonly onOpenDrawer: () => void; | ||
| 77 | readonly onOpenConnectionEditor: () => void; | 74 | readonly onOpenConnectionEditor: () => void; |
| 78 | readonly onChangeDraftMessage: (value: string) => void; | 75 | readonly onChangeDraftMessage: (value: string) => void; |
| 79 | readonly onPickDraftImages: () => Promise<void>; | 76 | readonly onPickDraftImages: () => Promise<void>; |
| ... | @@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { | ... | @@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 208 | }); | 205 | }); |
| 209 | 206 | ||
| 210 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 207 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 211 | const { onOpenDrawer } = props; | ||
| 212 | |||
| 213 | const insets = useSafeAreaInsets(); | 208 | const insets = useSafeAreaInsets(); |
| 214 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 209 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 215 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); | 210 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| ... | @@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 263 | [props.serverConfig, selectedInstanceId], | 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 | useLayoutEffect(() => { | 261 | useLayoutEffect(() => { |
| 289 | selectedThreadKeyRef.current = selectedThreadKey; | 262 | selectedThreadKeyRef.current = selectedThreadKey; |
| 290 | }, [selectedThreadKey]); | 263 | }, [selectedThreadKey]); |
| ... | @@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 311 | return; | 284 | return; |
| 312 | } | 285 | } |
| 313 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; | 286 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; |
| 314 | void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { | 287 | // Wait for the keyboard dismissal (started by blur() on send) to finish |
| 315 | if ( | 288 | // before scrolling: scrollMessageToEnd freezes keyboard-driven inset |
| 316 | selectedThreadKeyRef.current !== targetThreadKey || | 289 | // updates while it runs, and a close event swallowed by that freeze |
| 317 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | 290 | // leaves the keyboard padding permanently applied — overshooting the |
| 318 | ) { | 291 | // anchor and leaving a phantom bottom inset once the reply streams in. |
| 319 | return; | 292 | void KeyboardController.dismiss() |
| 320 | } | 293 | .then(() => { |
| 321 | lastScrolledAnchorMessageIdRef.current = null; | 294 | if ( |
| 322 | freeze.set(false); | 295 | selectedThreadKeyRef.current !== targetThreadKey || |
| 323 | }); | 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 | return () => cancelAnimationFrame(frame); | 313 | return () => cancelAnimationFrame(frame); |
| 326 | }, [ | 314 | }, [ |
| ... | @@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 379 | }, []); | 367 | }, []); |
| 380 | 368 | ||
| 381 | return ( | 369 | return ( |
| 382 | <GestureDetector gesture={headerDrawerGesture}> | 370 | <View className="flex-1"> |
| 383 | <View className="flex-1"> | 371 | {showContent ? ( |
| 384 | {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 | <View | 411 | <View |
| 386 | style={{ flex: 1 }} | 412 | ref={composerOverlayRef} |
| 387 | onTouchStart={handleFeedTouchStart} | 413 | onLayout={onComposerLayout} |
| 388 | onTouchMove={handleFeedTouchMove} | 414 | style={{ width: "100%", paddingTop: 8 }} |
| 389 | onTouchEnd={handleFeedTouchEnd} | ||
| 390 | onTouchCancel={handleFeedTouchCancel} | ||
| 391 | > | 415 | > |
| 392 | <ThreadFeed | 416 | <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> |
| 393 | key={props.selectedThread.id} | 417 | {props.activeWorkStartedAt ? ( |
| 394 | environmentId={props.environmentId} | 418 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> |
| 395 | threadId={props.selectedThread.id} | 419 | ) : null} |
| 396 | workspaceRoot={props.threadCwd} | 420 | |
| 397 | feed={props.selectedThreadFeed} | 421 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 398 | contentPresentation={props.contentPresentation} | 422 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> |
| 399 | agentLabel={agentLabel} | 423 | {props.activePendingApproval ? ( |
| 400 | latestTurn={props.selectedThread.latestTurn} | 424 | <PendingApprovalCard |
| 401 | listRef={listRef} | 425 | approval={props.activePendingApproval} |
| 402 | freeze={freeze} | 426 | respondingApprovalId={props.respondingApprovalId} |
| 403 | anchorMessageId={anchorMessageId} | 427 | onRespond={props.onRespondToApproval} |
| 404 | contentInsetEndAdjustment={contentInsetEndAdjustment} | 428 | /> |
| 405 | contentTopInset={0} | 429 | ) : null} |
| 406 | contentBottomInset={estimatedOverlayHeight} | 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 | contentMaxWidth={contentMaxWidth} | 450 | contentMaxWidth={contentMaxWidth} |
| 408 | layoutVariant={layoutVariant} | 451 | connectionState={props.connectionStateLabel} |
| 409 | usesAutomaticContentInsets={props.usesAutomaticContentInsets} | 452 | connectionError={props.connectionError} |
| 410 | onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} | 453 | environmentLabel={props.environmentLabel} |
| 411 | skills={selectedProviderSkills} | 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 | </View> | 474 | </View> |
| 414 | ) : ( | 475 | </KeyboardStickyView> |
| 415 | <View style={{ flex: 1 }} /> | 476 | ) : null} |
| 416 | )} | 477 | </View> |
| 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> | ||
| 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,6 +1508,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1508 | // mount positions during attach, where UIKit applies the inset. | 1508 | // mount positions during attach, where UIKit applies the inset. |
| 1509 | key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} | 1509 | key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} |
| 1510 | style={{ flex: 1 }} | 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 | contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} | 1515 | contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"} |
| 1512 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} | 1516 | automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets} |
| 1513 | {...(usesNativeAutomaticInsets | 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,5 +1,8 @@ |
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | 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 | import { LegendList } from "@legendapp/list/react-native"; | 6 | import { LegendList } from "@legendapp/list/react-native"; |
| 4 | import type { MenuAction } from "@react-native-menu/menu"; | 7 | import type { MenuAction } from "@react-native-menu/menu"; |
| 5 | import { SymbolView } from "expo-symbols"; | 8 | import { SymbolView } from "expo-symbols"; |
| ... | @@ -107,6 +110,7 @@ interface ThreadNavigationSidebarProps { | ... | @@ -107,6 +110,7 @@ interface ThreadNavigationSidebarProps { |
| 107 | readonly selectedThreadKey: string | null; | 110 | readonly selectedThreadKey: string | null; |
| 108 | readonly onOpenSettings: () => void; | 111 | readonly onOpenSettings: () => void; |
| 109 | readonly onOpenEnvironmentSettings: () => void; | 112 | readonly onOpenEnvironmentSettings: () => void; |
| 113 | readonly onNewThreadInProject: (project: EnvironmentProject) => void; | ||
| 110 | readonly onSearchQueryChange: (query: string) => void; | 114 | readonly onSearchQueryChange: (query: string) => void; |
| 111 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; | 115 | readonly onSelectThread: (thread: EnvironmentThreadShell) => void; |
| 112 | readonly onRequestVisibility: () => void; | 116 | readonly onRequestVisibility: () => void; |
| ... | @@ -413,6 +417,11 @@ function ThreadNavigationSidebarPane( | ... | @@ -413,6 +417,11 @@ function ThreadNavigationSidebarPane( |
| 413 | isFirst={item.isFirst} | 417 | isFirst={item.isFirst} |
| 414 | groupKey={item.group.key} | 418 | groupKey={item.group.key} |
| 415 | onGroupAction={updateGroupDisplay} | 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 | project={item.group.representative} | 425 | project={item.group.representative} |
| 417 | threadCount={item.group.threads.length + item.group.pendingTasks.length} | 426 | threadCount={item.group.threads.length + item.group.pendingTasks.length} |
| 418 | title={item.group.title} | 427 | title={item.group.title} |
| ... | @@ -480,6 +489,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -480,6 +489,7 @@ function ThreadNavigationSidebarPane( |
| 480 | handleSwipeableWillOpen, | 489 | handleSwipeableWillOpen, |
| 481 | openPendingTask, | 490 | openPendingTask, |
| 482 | projectCwdByKey, | 491 | projectCwdByKey, |
| 492 | props.onNewThreadInProject, | ||
| 483 | props.selectedThreadKey, | 493 | props.selectedThreadKey, |
| 484 | props.width, | 494 | props.width, |
| 485 | savedConnectionsById, | 495 | savedConnectionsById, |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+26-36| ... | @@ -49,7 +49,6 @@ import { | ... | @@ -49,7 +49,6 @@ import { |
| 49 | useThreadGitRightHeaderItems, | 49 | useThreadGitRightHeaderItems, |
| 50 | } from "./ThreadGitControls"; | 50 | } from "./ThreadGitControls"; |
| 51 | import { GitOverviewSheet } from "./git/GitOverviewSheet"; | 51 | import { GitOverviewSheet } from "./git/GitOverviewSheet"; |
| 52 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | ||
| 53 | import { useAtomCommand } from "../../state/use-atom-command"; | 52 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 54 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | 53 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| 55 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; | 54 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; |
| ... | @@ -197,7 +196,6 @@ function ThreadRouteContent( | ... | @@ -197,7 +196,6 @@ function ThreadRouteContent( |
| 197 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); | 196 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); |
| 198 | const navigation = useNavigation(); | 197 | const navigation = useNavigation(); |
| 199 | const params = props.route.params; | 198 | const params = props.route.params; |
| 200 | const [drawerVisible, setDrawerVisible] = useState(false); | ||
| 201 | const environmentIdRaw = firstRouteParam(params.environmentId); | 199 | const environmentIdRaw = firstRouteParam(params.environmentId); |
| 202 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | 200 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; |
| 203 | const threadId = firstRouteParam(params.threadId); | 201 | const threadId = firstRouteParam(params.threadId); |
| ... | @@ -326,18 +324,6 @@ function ThreadRouteContent( | ... | @@ -326,18 +324,6 @@ function ThreadRouteContent( |
| 326 | ); | 324 | ); |
| 327 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | 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 | const handleOpenGitInspector = useCallback(() => { | 327 | const handleOpenGitInspector = useCallback(() => { |
| 342 | setInspectorSelection({ routeThreadIdentity, mode: "git" }); | 328 | setInspectorSelection({ routeThreadIdentity, mode: "git" }); |
| 343 | showAuxiliaryPane("inspector"); | 329 | showAuxiliaryPane("inspector"); |
| ... | @@ -655,6 +641,22 @@ function ThreadRouteContent( | ... | @@ -655,6 +641,22 @@ function ThreadRouteContent( |
| 655 | ], | 641 | ], |
| 656 | [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], | 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 | if (!environmentId || !threadId) { | 661 | if (!environmentId || !threadId) { |
| 660 | return <OpeningThreadLoadingScreen />; | 662 | return <OpeningThreadLoadingScreen />; |
| ... | @@ -704,7 +706,6 @@ function ThreadRouteContent( | ... | @@ -704,7 +706,6 @@ function ThreadRouteContent( |
| 704 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | 706 | selectedThreadQueueCount={composer.selectedThreadQueueCount} |
| 705 | layoutVariant={layout.variant} | 707 | layoutVariant={layout.variant} |
| 706 | usesAutomaticContentInsets={usesNativeHeaderGlass} | 708 | usesAutomaticContentInsets={usesNativeHeaderGlass} |
| 707 | onOpenDrawer={handleOpenDrawer} | ||
| 708 | onOpenConnectionEditor={handleOpenConnectionEditor} | 709 | onOpenConnectionEditor={handleOpenConnectionEditor} |
| 709 | onChangeDraftMessage={composer.onChangeDraftMessage} | 710 | onChangeDraftMessage={composer.onChangeDraftMessage} |
| 710 | onPickDraftImages={composer.onPickDraftImages} | 711 | onPickDraftImages={composer.onPickDraftImages} |
| ... | @@ -722,23 +723,6 @@ function ThreadRouteContent( | ... | @@ -722,23 +723,6 @@ function ThreadRouteContent( |
| 722 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} | 723 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} |
| 723 | onSubmitUserInput={requests.onSubmitUserInput} | 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 | </View> | 726 | </View> |
| 743 | </> | 727 | </> |
| 744 | ); | 728 | ); |
| ... | @@ -757,11 +741,17 @@ function ThreadRouteContent( | ... | @@ -757,11 +741,17 @@ function ThreadRouteContent( |
| 757 | : undefined, | 741 | : undefined, |
| 758 | title: selectedThread.title, | 742 | title: selectedThread.title, |
| 759 | headerBackVisible: !layout.usesSplitView, | 743 | headerBackVisible: !layout.usesSplitView, |
| 760 | // Compact uses the NATIVE back button (Thread lives flat in the root | 744 | // Compact uses the NATIVE back button when a previous route exists; |
| 761 | // stack now, so a real previous route exists); only split view needs | 745 | // deep links / cold starts get an explicit Home button instead. |
| 762 | // custom left items. | 746 | // Split view always uses its custom left items. |
| 763 | unstable_headerLeftItems: | 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 | // Search lives in the persistent sidebar, so the split header keeps | 755 | // Search lives in the persistent sidebar, so the split header keeps |
| 766 | // the git controls on the RIGHT (no center items — center space is | 756 | // the git controls on the RIGHT (no center items — center space is |
| 767 | // reserved for future breadcrumbs/status). | 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,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 | const projectsForEnvironment = useMemo( | 234 | const projectsForEnvironment = useMemo( |
| 269 | () => | 235 | () => |
| 270 | pipe( | 236 | pipe( |
| ... | @@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 310 | scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) | 276 | scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) |
| 311 | ? editingPendingProject | 277 | ? editingPendingProject |
| 312 | : (projectsForEnvironment[0] ?? null)); | 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 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( | 335 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| 314 | selectedProject?.environmentId ?? null, | 336 | selectedProject?.environmentId ?? null, |
| 315 | ); | 337 | ); |
| ... | @@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 480 | setSelectedProjectKey(nextProjectKey); | 502 | setSelectedProjectKey(nextProjectKey); |
| 481 | }, []); | 503 | }, []); |
| 482 | 504 | ||
| 483 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { | 505 | const selectEnvironment = useCallback( |
| 484 | setSelectedEnvironmentId(environmentId); | 506 | (environmentId: EnvironmentId) => { |
| 485 | setSelectedProjectKey(null); | 507 | const projectsOnTarget = projects.filter( |
| 486 | }, []); | 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 | const setWorkspaceMode = useCallback( | 536 | const setWorkspaceMode = useCallback( |
| 489 | (mode: WorkspaceMode) => { | 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,31 +43,54 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 43 | readonly isFirst: boolean; | 43 | readonly isFirst: boolean; |
| 44 | readonly groupKey: string; | 44 | readonly groupKey: string; |
| 45 | readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; | 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"); | 50 | const iconMutedColor = useThemeColor("--color-icon-muted"); |
| 48 | const { groupKey, onGroupAction } = props; | 51 | const { groupKey, onGroupAction, onNewThread } = props; |
| 52 | const newThreadTarget = props.newThreadTarget ?? null; | ||
| 49 | const compact = props.variant === "compact"; | 53 | const compact = props.variant === "compact"; |
| 50 | const handleToggle = useCallback( | 54 | const handleToggle = useCallback( |
| 51 | () => onGroupAction(groupKey, "toggle-collapsed"), | 55 | () => onGroupAction(groupKey, "toggle-collapsed"), |
| 52 | [groupKey, onGroupAction], | 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 | return ( | 71 | return ( |
| 56 | <Pressable | 72 | <View |
| 57 | accessibilityRole="button" | 73 | className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"} |
| 58 | accessibilityState={{ expanded: !props.collapsed }} | 74 | style={{ |
| 59 | accessibilityLabel={`${props.title}, ${props.threadCount} threads`} | 75 | minHeight: compact ? 44 : 36, |
| 60 | accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"} | 76 | paddingLeft: compact ? 20 : 12, |
| 61 | className={compact ? "bg-screen" : undefined} | 77 | // Compact right padding centers the 20pt plus glyph on the thread |
| 62 | onPress={handleToggle} | 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 | className={ | 89 | className={ |
| 66 | compact | 90 | compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2" |
| 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"}` | ||
| 69 | } | 91 | } |
| 70 | style={{ minHeight: compact ? 44 : 36 }} | 92 | hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }} |
| 93 | onPress={handleToggle} | ||
| 71 | > | 94 | > |
| 72 | <ProjectFavicon | 95 | <ProjectFavicon |
| 73 | environmentId={props.project.environmentId} | 96 | environmentId={props.project.environmentId} |
| ... | @@ -95,15 +118,25 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: | ... | @@ -95,15 +118,25 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: |
| 95 | > | 118 | > |
| 96 | {props.threadCount} | 119 | {props.threadCount} |
| 97 | </Text> | 120 | </Text> |
| 98 | <SymbolView | 121 | </Pressable> |
| 99 | name={props.collapsed ? "chevron.right" : "chevron.down"} | 122 | {showNewThreadButton ? ( |
| 100 | size={compact ? 13 : 11} | 123 | <Pressable |
| 101 | tintColor={iconSubtleColor} | 124 | accessibilityLabel={`Create new thread in ${props.title}`} |
| 102 | type="monochrome" | 125 | accessibilityRole="button" |
| 103 | weight="semibold" | 126 | hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }} |
| 104 | /> | 127 | onPress={handleNewThread} |
| 105 | </View> | 128 | style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} |
| 106 | </Pressable> | 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,12 +1,13 @@ |
| 1 | export function relativeTime(input: string): string { | 1 | export function relativeTime(input: string): string { |
| 2 | const timestamp = Date.parse(input); | 2 | const timestamp = Date.parse(input); |
| 3 | if (Number.isNaN(timestamp)) { | 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 | const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); | 9 | const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); |
| 8 | if (deltaSeconds < 10) return "now"; | 10 | if (deltaSeconds < 60) return "<1m"; |
| 9 | if (deltaSeconds < 60) return `${deltaSeconds}s`; | ||
| 10 | 11 | ||
| 11 | const deltaMinutes = Math.floor(deltaSeconds / 60); | 12 | const deltaMinutes = Math.floor(deltaSeconds / 60); |
| 12 | if (deltaMinutes < 60) return `${deltaMinutes}m`; | 13 | if (deltaMinutes < 60) return `${deltaMinutes}m`; |
pnpm-lock.yaml+5-5| ... | @@ -378,8 +378,8 @@ importers: | ... | @@ -378,8 +378,8 @@ importers: |
| 378 | specifier: ^0.2.2 | 378 | specifier: ^0.2.2 |
| 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) | 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 | react-native-keyboard-controller: | 380 | react-native-keyboard-controller: |
| 381 | specifier: 1.21.6 | 381 | specifier: 1.21.13 |
| 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) | 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 | react-native-nitro-markdown: | 383 | react-native-nitro-markdown: |
| 384 | specifier: ^0.5.0 | 384 | specifier: ^0.5.0 |
| 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) | 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,8 +8814,8 @@ packages: |
| 8814 | react: '*' | 8814 | react: '*' |
| 8815 | react-native: '*' | 8815 | react-native: '*' |
| 8816 | 8816 | ||
| 8817 | react-native-keyboard-controller@1.21.6: | 8817 | react-native-keyboard-controller@1.21.13: |
| 8818 | resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} | 8818 | resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==} |
| 8819 | peerDependencies: | 8819 | peerDependencies: |
| 8820 | react: '*' | 8820 | react: '*' |
| 8821 | react-native: '*' | 8821 | react-native: '*' |
| ... | @@ -18950,7 +18950,7 @@ snapshots: | ... | @@ -18950,7 +18950,7 @@ snapshots: |
| 18950 | react: 19.2.3 | 18950 | react: 19.2.3 |
| 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) | 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 | dependencies: | 18954 | dependencies: |
| 18955 | react: 19.2.3 | 18955 | react: 19.2.3 |
| 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) | 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) |