| author | |
| committer | |
| log | bfba7781681eaa03eb465ce3d9a4ec07bf952b78 |
| tree | 0fed6105f1ebf536c52dc6143b149ed2639aec7f |
| parent | b438447f67b6b61bbe6f564d8b78fd90702117c5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>5 files changed, 202 insertions(+), 33 deletions(-)
apps/mobile/src/features/threads/new-task-flow-provider.tsx+22-28| ... | @@ -92,6 +92,7 @@ import { | ... | @@ -92,6 +92,7 @@ import { |
| 92 | resolveNewTaskBranchWorktreePath, | 92 | resolveNewTaskBranchWorktreePath, |
| 93 | resolveNewTaskLocalWorkspaceSelection, | 93 | resolveNewTaskLocalWorkspaceSelection, |
| 94 | } from "./new-task-context-presentation"; | 94 | } from "./new-task-context-presentation"; |
| 95 | import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; | ||
| 95 | 96 | ||
| 96 | type WorkspaceMode = "local" | "worktree"; | 97 | type WorkspaceMode = "local" | "worktree"; |
| 97 | 98 | ||
| ... | @@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 622 | ); | 623 | ); |
| 623 | }, [availableBranches, branchQuery]); | 624 | }, [availableBranches, branchQuery]); |
| 624 | 625 | ||
| 625 | const setProject = useCallback( | 626 | // New-task drafts are keyed per (environment, project), so retargeting the |
| 627 | // composer would otherwise show the target's empty draft and strand what the | ||
| 628 | // user typed under the old key. | ||
| 629 | const carryDraftContentTo = useCallback( | ||
| 626 | (project: EnvironmentProject) => { | 630 | (project: EnvironmentProject) => { |
| 627 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | 631 | const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; |
| 628 | const nextDraftKey = `new-task:${nextProjectKey}`; | ||
| 629 | if ( | 632 | if ( |
| 630 | selectedProjectDraftKey?.startsWith("new-task:") && | 633 | selectedProjectDraftKey?.startsWith("new-task:") && |
| 631 | selectedProjectDraftKey !== nextDraftKey | 634 | selectedProjectDraftKey !== nextDraftKey |
| 632 | ) { | 635 | ) { |
| 633 | void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); | 636 | void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); |
| 634 | } | 637 | } |
| 635 | setSelectedEnvironmentId(project.environmentId); | ||
| 636 | setSelectedProjectKey(nextProjectKey); | ||
| 637 | }, | 638 | }, |
| 638 | [selectedProjectDraftKey], | 639 | [selectedProjectDraftKey], |
| 639 | ); | 640 | ); |
| 640 | 641 | ||
| 642 | const setProject = useCallback( | ||
| 643 | (project: EnvironmentProject) => { | ||
| 644 | carryDraftContentTo(project); | ||
| 645 | setSelectedEnvironmentId(project.environmentId); | ||
| 646 | setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); | ||
| 647 | }, | ||
| 648 | [carryDraftContentTo], | ||
| 649 | ); | ||
| 650 | |||
| 641 | const selectEnvironment = useCallback( | 651 | const selectEnvironment = useCallback( |
| 642 | (environmentId: EnvironmentId) => { | 652 | (environmentId: EnvironmentId) => { |
| 643 | const projectsOnTarget = projects.filter( | 653 | const match = resolveEnvironmentProjectMatch( |
| 644 | (project) => project.environmentId === environmentId, | 654 | projects.filter((project) => project.environmentId === environmentId), |
| 655 | selectedProject, | ||
| 645 | ); | 656 | ); |
| 646 | const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | 657 | if (match) { |
| 647 | // Prefer the repository identity; projects without one (e.g. not yet | 658 | carryDraftContentTo(match); |
| 648 | // indexed) fall back to workspace basename, then title, so switching | 659 | } |
| 649 | // computers still follows the same repo instead of resetting to | ||
| 650 | // whatever project is first on the target machine. | ||
| 651 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | ||
| 652 | const match = | ||
| 653 | (repositoryKey !== null | ||
| 654 | ? projectsOnTarget.find( | ||
| 655 | (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, | ||
| 656 | ) | ||
| 657 | : undefined) ?? | ||
| 658 | (workspaceBasename !== null | ||
| 659 | ? projectsOnTarget.find( | ||
| 660 | (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, | ||
| 661 | ) | ||
| 662 | : undefined) ?? | ||
| 663 | (selectedProject !== null | ||
| 664 | ? projectsOnTarget.find((project) => project.title === selectedProject.title) | ||
| 665 | : undefined); | ||
| 666 | setSelectedEnvironmentId(environmentId); | 660 | setSelectedEnvironmentId(environmentId); |
| 667 | setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); | 661 | setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); |
| 668 | }, | 662 | }, |
| 669 | [projects, selectedProject], | 663 | [projects, selectedProject, carryDraftContentTo], |
| 670 | ); | 664 | ); |
| 671 | 665 | ||
| 672 | const setWorkspaceMode = useCallback( | 666 | const setWorkspaceMode = useCallback( |
apps/mobile/src/features/threads/new-task-project-selection.test.ts+71-4| ... | @@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList"; | ... | @@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList"; |
| 6 | import { | 6 | import { |
| 7 | getProjectScopeSelectionTarget, | 7 | getProjectScopeSelectionTarget, |
| 8 | resolveDraftProjectSelection, | 8 | resolveDraftProjectSelection, |
| 9 | resolveEnvironmentProjectMatch, | ||
| 9 | } from "./new-task-project-selection"; | 10 | } from "./new-task-project-selection"; |
| 10 | 11 | ||
| 11 | function makeProject(id: string, environmentId = "environment"): EnvironmentProject { | 12 | function makeProject( |
| 13 | id: string, | ||
| 14 | environmentId = "environment", | ||
| 15 | options: { | ||
| 16 | readonly title?: string; | ||
| 17 | readonly workspaceRoot?: string; | ||
| 18 | readonly repositoryKey?: string; | ||
| 19 | } = {}, | ||
| 20 | ): EnvironmentProject { | ||
| 12 | return { | 21 | return { |
| 13 | environmentId: EnvironmentId.make(environmentId), | 22 | environmentId: EnvironmentId.make(environmentId), |
| 14 | id: ProjectId.make(id), | 23 | id: ProjectId.make(id), |
| 15 | title: id, | 24 | title: options.title ?? id, |
| 16 | workspaceRoot: `/work/${id}`, | 25 | workspaceRoot: options.workspaceRoot ?? `/work/${id}`, |
| 17 | repositoryIdentity: null, | 26 | repositoryIdentity: options.repositoryKey |
| 27 | ? { | ||
| 28 | canonicalKey: options.repositoryKey, | ||
| 29 | locator: { | ||
| 30 | source: "git-remote", | ||
| 31 | remoteName: "origin", | ||
| 32 | remoteUrl: `https://${options.repositoryKey}.git`, | ||
| 33 | }, | ||
| 34 | } | ||
| 35 | : null, | ||
| 18 | defaultModelSelection: null, | 36 | defaultModelSelection: null, |
| 19 | scripts: [], | 37 | scripts: [], |
| 20 | createdAt: "2026-07-01T00:00:00.000Z", | 38 | createdAt: "2026-07-01T00:00:00.000Z", |
| ... | @@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { | ... | @@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { |
| 51 | }); | 69 | }); |
| 52 | }); | 70 | }); |
| 53 | 71 | ||
| 72 | describe("resolveEnvironmentProjectMatch", () => { | ||
| 73 | it("follows the same repository onto the target machine", () => { | ||
| 74 | const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); | ||
| 75 | const target = [ | ||
| 76 | makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }), | ||
| 77 | makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), | ||
| 78 | ]; | ||
| 79 | expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("falls back to workspace basename, then title, for unindexed projects", () => { | ||
| 83 | const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); | ||
| 84 | const byBasename = [ | ||
| 85 | makeProject("other", "server"), | ||
| 86 | makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), | ||
| 87 | ]; | ||
| 88 | expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); | ||
| 89 | |||
| 90 | const byTitle = [ | ||
| 91 | makeProject("other", "server"), | ||
| 92 | makeProject("srv", "server", { title: "t3code" }), | ||
| 93 | ]; | ||
| 94 | expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("does not treat a known different repository as a basename or title match", () => { | ||
| 98 | const selected = makeProject("t3code", "mac", { | ||
| 99 | repositoryKey: "github.com/t3tools/t3code", | ||
| 100 | workspaceRoot: "/Users/me/t3code", | ||
| 101 | }); | ||
| 102 | const fork = makeProject("fork", "server", { | ||
| 103 | repositoryKey: "github.com/someone/t3code", | ||
| 104 | title: "t3code", | ||
| 105 | workspaceRoot: "/home/me/t3code", | ||
| 106 | }); | ||
| 107 | const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); | ||
| 108 | expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); | ||
| 109 | // Without any weaker match the fork is still the first-project fallback. | ||
| 110 | expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("falls back to the first project on the target so the draft has a key to carry over to", () => { | ||
| 114 | const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); | ||
| 115 | const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; | ||
| 116 | expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); | ||
| 117 | expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | |||
| 54 | describe("resolveDraftProjectSelection", () => { | 121 | describe("resolveDraftProjectSelection", () => { |
| 55 | it("preserves an explicit project selection", () => { | 122 | it("preserves an explicit project selection", () => { |
| 56 | const project = makeProject("t3code"); | 123 | const project = makeProject("t3code"); |
apps/mobile/src/features/threads/new-task-project-selection.ts+47| ... | @@ -26,6 +26,53 @@ function getOnlySelectableProject( | ... | @@ -26,6 +26,53 @@ function getOnlySelectableProject( |
| 26 | return onlyScope?.representative ?? null; | 26 | return onlyScope?.representative ?? null; |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | /** | ||
| 30 | * Picks the project on a target environment that corresponds to the project | ||
| 31 | * currently selected in the new-task flow, so switching computers follows the | ||
| 32 | * same repo. Repository identity is preferred; projects without one (e.g. not | ||
| 33 | * yet indexed) fall back to workspace basename, then title. When nothing | ||
| 34 | * matches, the first project on the target stands in — the same fallback the | ||
| 35 | * render path applies when no key is selected — so the draft always has a | ||
| 36 | * concrete key to carry over to. | ||
| 37 | */ | ||
| 38 | export function resolveEnvironmentProjectMatch( | ||
| 39 | projectsOnTarget: ReadonlyArray<EnvironmentProject>, | ||
| 40 | selectedProject: EnvironmentProject | null, | ||
| 41 | ): EnvironmentProject | null { | ||
| 42 | const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | ||
| 43 | // `|| null` (not `??`): a pending-task placeholder project can have an empty | ||
| 44 | // workspaceRoot, and an "" basename would match nothing meaningful. | ||
| 45 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | ||
| 46 | // The weaker signals only apply where identity is unknown on at least one | ||
| 47 | // side; two known, different repositories never match on a shared basename | ||
| 48 | // or title (mirrors the environment list filter in the new-task flow). | ||
| 49 | const isKnownMismatch = (project: EnvironmentProject) => { | ||
| 50 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | ||
| 51 | return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; | ||
| 52 | }; | ||
| 53 | return ( | ||
| 54 | (repositoryKey !== null | ||
| 55 | ? projectsOnTarget.find( | ||
| 56 | (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, | ||
| 57 | ) | ||
| 58 | : undefined) ?? | ||
| 59 | (workspaceBasename !== null | ||
| 60 | ? projectsOnTarget.find( | ||
| 61 | (project) => | ||
| 62 | !isKnownMismatch(project) && | ||
| 63 | project.workspaceRoot.split("/").at(-1) === workspaceBasename, | ||
| 64 | ) | ||
| 65 | : undefined) ?? | ||
| 66 | (selectedProject !== null | ||
| 67 | ? projectsOnTarget.find( | ||
| 68 | (project) => !isKnownMismatch(project) && project.title === selectedProject.title, | ||
| 69 | ) | ||
| 70 | : undefined) ?? | ||
| 71 | projectsOnTarget[0] ?? | ||
| 72 | null | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 29 | export function resolveDraftProjectSelection( | 76 | export function resolveDraftProjectSelection( |
| 30 | selectedProjectKey: string | null, | 77 | selectedProjectKey: string | null, |
| 31 | projects: ReadonlyArray<EnvironmentProject>, | 78 | projects: ReadonlyArray<EnvironmentProject>, |
apps/mobile/src/state/use-composer-drafts.test.ts+43| ... | @@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ | ... | @@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ |
| 145 | loadIncomingShareDrafts: incomingShareStorageMocks.load, | 145 | loadIncomingShareDrafts: incomingShareStorageMocks.load, |
| 146 | })); | 146 | })); |
| 147 | 147 | ||
| 148 | import type { DraftComposerAttachment } from "../lib/composerImages"; | ||
| 148 | import { appAtomRegistry } from "./atom-registry"; | 149 | import { appAtomRegistry } from "./atom-registry"; |
| 149 | import { threadOutboxManager } from "./thread-outbox"; | 150 | import { threadOutboxManager } from "./thread-outbox"; |
| 150 | import { | 151 | import { |
| ... | @@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => { | ... | @@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => { |
| 1433 | expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); | 1434 | expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); |
| 1434 | }); | 1435 | }); |
| 1435 | 1436 | ||
| 1437 | it("drops another environment's upload stamp when carrying attachments across machines", () => { | ||
| 1438 | const sourceKey = "new-task:environment-1:project-1"; | ||
| 1439 | const targetKey = "new-task:environment-2:project-2"; | ||
| 1440 | const uploadedElsewhere: DraftComposerAttachment = { | ||
| 1441 | id: "image-1", | ||
| 1442 | type: "image", | ||
| 1443 | name: "screen.png", | ||
| 1444 | mimeType: "image/png", | ||
| 1445 | sizeBytes: 1, | ||
| 1446 | previewUri: "file:///drafts/screen.png", | ||
| 1447 | fileUri: "file:///drafts/screen.png", | ||
| 1448 | uploadedAttachmentId: "upload-1", | ||
| 1449 | uploadEnvironmentId: EnvironmentId.make("environment-1"), | ||
| 1450 | }; | ||
| 1451 | const uploadedOnTarget: DraftComposerAttachment = { | ||
| 1452 | ...uploadedElsewhere, | ||
| 1453 | id: "image-2", | ||
| 1454 | uploadedAttachmentId: "upload-2", | ||
| 1455 | uploadEnvironmentId: EnvironmentId.make("environment-2"), | ||
| 1456 | }; | ||
| 1457 | |||
| 1458 | const next = copyComposerDraftContentState( | ||
| 1459 | { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, | ||
| 1460 | sourceKey, | ||
| 1461 | targetKey, | ||
| 1462 | ); | ||
| 1463 | |||
| 1464 | expect(next[targetKey]?.attachments).toEqual([ | ||
| 1465 | { | ||
| 1466 | id: "image-1", | ||
| 1467 | type: "image", | ||
| 1468 | name: "screen.png", | ||
| 1469 | mimeType: "image/png", | ||
| 1470 | sizeBytes: 1, | ||
| 1471 | previewUri: "file:///drafts/screen.png", | ||
| 1472 | fileUri: "file:///drafts/screen.png", | ||
| 1473 | }, | ||
| 1474 | uploadedOnTarget, | ||
| 1475 | ]); | ||
| 1476 | expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); | ||
| 1477 | }); | ||
| 1478 | |||
| 1436 | it("merges shared content into a project draft without duplicating retries", () => { | 1479 | it("merges shared content into a project draft without duplicating retries", () => { |
| 1437 | const draftKey = "new-task:environment-1:project-1"; | 1480 | const draftKey = "new-task:environment-1:project-1"; |
| 1438 | const sharedAttachment = { | 1481 | const sharedAttachment = { |
apps/mobile/src/state/use-composer-drafts.ts+19-1| ... | @@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState( | ... | @@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState( |
| 1049 | if (!sourceHasContent || targetHasContent) { | 1049 | if (!sourceHasContent || targetHasContent) { |
| 1050 | return current; | 1050 | return current; |
| 1051 | } | 1051 | } |
| 1052 | // Pending uploads live on one server. Crossing environments keeps the local | ||
| 1053 | // bytes (the upload worker re-sends them to the new key's environment) but | ||
| 1054 | // drops the old stamp, so it cannot pin the source environment's pending | ||
| 1055 | // upload alive from the copy. | ||
| 1056 | const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); | ||
| 1057 | const attachments = source.attachments.map((attachment) => | ||
| 1058 | attachment.uploadEnvironmentId !== undefined && | ||
| 1059 | attachment.uploadEnvironmentId !== targetEnvironmentId | ||
| 1060 | ? stripAttachmentUploadReference(attachment) | ||
| 1061 | : attachment, | ||
| 1062 | ); | ||
| 1052 | return { | 1063 | return { |
| 1053 | ...current, | 1064 | ...current, |
| 1054 | [targetDraftKey]: { | 1065 | [targetDraftKey]: { |
| 1055 | ...target, | 1066 | ...target, |
| 1056 | text: source.text, | 1067 | text: source.text, |
| 1057 | attachments: source.attachments, | 1068 | attachments, |
| 1058 | ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), | 1069 | ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), |
| 1059 | }, | 1070 | }, |
| 1060 | }; | 1071 | }; |
| 1061 | } | 1072 | } |
| 1062 | 1073 | ||
| 1074 | function stripAttachmentUploadReference( | ||
| 1075 | attachment: DraftComposerAttachment, | ||
| 1076 | ): DraftComposerAttachment { | ||
| 1077 | const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment; | ||
| 1078 | return rest; | ||
| 1079 | } | ||
| 1080 | |||
| 1063 | export async function copyComposerDraftContentIfEmpty( | 1081 | export async function copyComposerDraftContentIfEmpty( |
| 1064 | sourceDraftKey: string, | 1082 | sourceDraftKey: string, |
| 1065 | targetDraftKey: string, | 1083 | targetDraftKey: string, |