authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-07 03:48:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 03:48:14-07:00
log8b2838e0e8a73d3fa6476940445c372e47b99db4
tree3a5abcd1abc0c6b754859c0acf052fc13e0eda75
parentdc39615aec702ea6d402168f80b4d1613f4f2e0f
signature Signed by PGP key B5690EEEBB952194

feat(web): group onboarding project import by repository (#10493)

Onboarding listed every directory Claude Code or Codex had ever run in as one flat list of paths, with everything from the last 30 days preselected. On my machine that was 270 rows and 80 preselected. Most of them were Codex scratch folders, worktrees, and one-off questions. I wanted two or three projects and had no fast way to get there. The scanner now reads each candidate's `.git/config` directly, so the client can group clones by origin and show the GitHub `owner/name`. Linked worktrees, Codex scratch directories under `~/Documents/Codex`, `~/Downloads`, and temp roots are no longer offered. Folders that are not git repositories collapse under "Other folders". The default selection requires a git repository with at least three threads. Select all and Select none sit above the list, and each row shows the source icons, thread count, and last activity. On the same machine this drops the list to 162 rows and the default selection to 16. Mobile has no project import step, so there is no mobile change. Created with Claude Fable 5.1 in Claude Code.

9 files changed, 764 insertions(+), 49 deletions(-)

apps/server/src/project/AgentSessionScanner.test.ts+118
...@@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
213 threadCount: 1,213 threadCount: 1,
214 lastActiveAt: "2026-03-01T00:00:00.000Z",214 lastActiveAt: "2026-03-01T00:00:00.000Z",
215 alreadyImported: false,215 alreadyImported: false,
216 git: null,
216 },217 },
217 {218 {
218 path: olderWorkspace,219 path: olderWorkspace,
...@@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
221 threadCount: 2,222 threadCount: 2,
222 lastActiveAt: "2026-01-02T00:00:00.000Z",223 lastActiveAt: "2026-01-02T00:00:00.000Z",
223 alreadyImported: false,224 alreadyImported: false,
225 git: null,
224 },226 },
225 ]);227 ]);
226 }),228 }),
...@@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
263 threadCount: 1,265 threadCount: 1,
264 lastActiveAt: "2026-02-09T11:00:00.000Z",266 lastActiveAt: "2026-02-09T11:00:00.000Z",
265 alreadyImported: false,267 alreadyImported: false,
268 git: null,
266 },269 },
267 {270 {
268 path: workspace,271 path: workspace,
...@@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
271 threadCount: 2,274 threadCount: 2,
272 lastActiveAt: "2026-02-09T10:00:00.000Z",275 lastActiveAt: "2026-02-09T10:00:00.000Z",
273 alreadyImported: false,276 alreadyImported: false,
277 git: null,
274 },278 },
275 ]);279 ]);
276 }),280 }),
...@@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
389 threadCount: 2,393 threadCount: 2,
390 lastActiveAt: "2026-04-01T09:00:00.000Z",394 lastActiveAt: "2026-04-01T09:00:00.000Z",
391 alreadyImported: true,395 alreadyImported: true,
396 git: null,
392 },397 },
393 ]);398 ]);
394 }),399 }),
...@@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
421 path: workspace,426 path: workspace,
422 projectId: ProjectId.make("project-1"),427 projectId: ProjectId.make("project-1"),
423 alreadyImported: true,428 alreadyImported: true,
429 git: null,
424 });430 });
425 }),431 }),
426 );432 );
...@@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
452 path: workspaceAlias,458 path: workspaceAlias,
453 projectId: ProjectId.make("project-1"),459 projectId: ProjectId.make("project-1"),
454 alreadyImported: true,460 alreadyImported: true,
461 git: null,
455 });462 });
456 }),463 }),
457 );464 );
...@@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
498 threadCount: 2,505 threadCount: 2,
499 lastActiveAt: "2026-01-02T00:00:00.000Z",506 lastActiveAt: "2026-01-02T00:00:00.000Z",
500 alreadyImported: true,507 alreadyImported: true,
508 git: null,
501 },509 },
502 ]);510 ]);
503 }),511 }),
...@@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
855 }),863 }),
856 );864 );
857865
866 it.effect("excludes Codex scratch directories and Downloads", () =>
867 Effect.gen(function* () {
868 const path = yield* Path.Path;
869 const fileSystem = yield* FileSystem.FileSystem;
870 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
871 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
872 // The exclusions key off the real home directory, so these fixtures
873 // must live there. Each run owns a uniquely named subtree and removes
874 // only that subtree, never the shared Codex or Downloads parents.
875 const home = NodeOS.homedir();
876 // Borrow a unique suffix from a scoped temp dir instead of reaching for
877 // Date.now or Math.random, which the Effect lint rejects.
878 const runId = path.basename(yield* makeTempDir("t3code-scanner-test-"));
879 const scratchRoot = path.join(home, "Documents", "Codex", runId);
880 const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation");
881 const downloads = path.join(home, "Downloads", runId);
882 const keep = yield* makeTempDir("t3code-workspace-keep-");
883 yield* fileSystem.makeDirectory(scratch, { recursive: true });
884 yield* fileSystem.makeDirectory(downloads, { recursive: true });
885 yield* Effect.addFinalizer(() =>
886 Effect.all([
887 fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore),
888 fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore),
889 ]),
890 );
891
892 for (const [index, cwd] of [scratch, downloads, keep].entries()) {
893 yield* writeTranscript({
894 filePath: path.join(
895 codexHomePath,
896 "sessions",
897 "2026",
898 "09",
899 "01",
900 `rollout-${index}.jsonl`,
901 ),
902 contents: codexRolloutLine(cwd),
903 mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"),
904 });
905 }
906
907 const result = yield* runScan({ claudeHomePath, codexHomePath });
908
909 expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
910 }),
911 );
912
913 it.effect("skips linked git worktrees and reports the origin of real checkouts", () =>
914 Effect.gen(function* () {
915 const path = yield* Path.Path;
916 const fileSystem = yield* FileSystem.FileSystem;
917 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
918 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
919 const repo = yield* makeTempDir("t3code-workspace-repo-");
920 const worktree = yield* makeTempDir("t3code-workspace-worktree-");
921 const plain = yield* makeTempDir("t3code-workspace-plain-");
922 const noRemote = yield* makeTempDir("t3code-workspace-noremote-");
923 const submodule = yield* makeTempDir("t3code-workspace-submodule-");
924
925 yield* fileSystem.makeDirectory(path.join(repo, ".git"));
926 yield* fileSystem.writeFileString(
927 path.join(repo, ".git", "config"),
928 '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n',
929 );
930 yield* fileSystem.writeFileString(
931 path.join(worktree, ".git"),
932 `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`,
933 );
934 yield* fileSystem.makeDirectory(path.join(noRemote, ".git"));
935 yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n");
936 // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`.
937 const submoduleGitDir = path.join(repo, ".git", "modules", "vendor");
938 yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true });
939 yield* fileSystem.writeFileString(
940 path.join(submoduleGitDir, "config"),
941 '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n',
942 );
943 yield* fileSystem.writeFileString(
944 path.join(submodule, ".git"),
945 `gitdir: ${submoduleGitDir}\n`,
946 );
947
948 for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) {
949 yield* writeTranscript({
950 filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
951 contents: claudeSessionLine(cwd),
952 mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`),
953 });
954 }
955
956 const result = yield* runScan({ claudeHomePath, codexHomePath });
957
958 expect(
959 result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })),
960 ).toEqual([
961 {
962 path: submodule,
963 git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" },
964 },
965 { path: noRemote, git: { remoteKey: null, repository: null } },
966 { path: plain, git: null },
967 {
968 path: repo,
969 git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" },
970 },
971 ]);
972 }),
973 );
974
858 it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>975 it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
859 Effect.gen(function* () {976 Effect.gen(function* () {
860 const path = yield* Path.Path;977 const path = yield* Path.Path;
...@@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
1231 threadCount: 1,1348 threadCount: 1,
1232 lastActiveAt: "2026-05-03T00:00:00.000Z",1349 lastActiveAt: "2026-05-03T00:00:00.000Z",
1233 alreadyImported: false,1350 alreadyImported: false,
1351 git: null,
1234 },1352 },
1235 ]);1353 ]);
1236 }),1354 }),
apps/server/src/project/AgentSessionScanner.ts+73-2
...@@ -38,6 +38,11 @@ import * as Schema from "effect/Schema";...@@ -38,6 +38,11 @@ import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";38import * as Semaphore from "effect/Semaphore";
39import * as Stream from "effect/Stream";39import * as Stream from "effect/Stream";
4040
41import {
42 normalizeGitRemoteUrl,
43 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
44 parseOriginUrlFromGitConfig,
45} from "@t3tools/shared/git";
41import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";46import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
42import { normalizeProjectPathForComparison } from "@t3tools/shared/path";47import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
4348
...@@ -624,14 +629,28 @@ export const make = Effect.gen(function* () {...@@ -624,14 +629,28 @@ export const make = Effect.gen(function* () {
624 // must case fold.629 // must case fold.
625 const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";630 const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";
626 const hostEnvironment = yield* HostProcessEnvironment;631 const hostEnvironment = yield* HostProcessEnvironment;
632 const homeDir = NodeOS.homedir();
633 // `/private/tmp` is what macOS reports for sessions started in `/tmp`.
627 const excludedProjectRoots = new Set(634 const excludedProjectRoots = new Set(
628 [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) =>635 [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) =>
629 normalizeProjectPathForComparison(path.resolve(directory)),636 normalizeProjectPathForComparison(path.resolve(directory)),
630 ),637 ),
631 );638 );
639 // Codex creates one scratch directory per conversation under
640 // ~/Documents/Codex/<date>/<slug>. Neither those nor anything a user
641 // unpacked into Downloads is a project.
642 const excludedProjectAncestors = [
643 path.join(homeDir, "Downloads"),
644 path.join(homeDir, "Documents", "Codex"),
645 ];
632646
633 const isExcludedProjectPath = (candidatePath: string) =>647 const isExcludedProjectPath = (candidatePath: string) =>
634 excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||648 excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
649 excludedProjectAncestors.some((ancestor) =>
650 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
651 normalizeForWorktreeMatch(ancestor, foldWorktreeCase),
652 ),
653 ) ||
635 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(654 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
636 normalizeForWorktreeMatch(baseDir, foldWorktreeCase),655 normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
637 ) ||656 ) ||
...@@ -664,6 +683,48 @@ export const make = Effect.gen(function* () {...@@ -664,6 +683,48 @@ export const make = Effect.gen(function* () {
664 return `path:${normalizeProjectPathForComparison(realPath)}`;683 return `path:${normalizeProjectPathForComparison(realPath)}`;
665 });684 });
666685
686 /**
687 * Git identity of a directory, or the reason it has none. Reads `.git`
688 * directly instead of spawning git so a scan over hundreds of candidates
689 * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
690 * `worktrees/` directory the checkout is a linked worktree, which
691 * onboarding skips because its history belongs to the main checkout.
692 * Submodules use the same pointer shape but live under `modules/`, and
693 * are offered like any other repository.
694 */
695 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
696 directory: string,
697 ): Effect.fn.Return<
698 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] }
699 | { readonly _tag: "Worktree" }
700 | { readonly _tag: "NotGit" }
701 > {
702 const gitPath = path.join(directory, ".git");
703 const gitStats = yield* statOption(gitPath);
704 if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
705 let gitDir = gitPath;
706 if (gitStats.value.type !== "Directory") {
707 const pointer = yield* fileSystem
708 .readFileString(gitPath)
709 .pipe(Effect.orElseSucceed(() => ""));
710 const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
711 if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
712 gitDir = path.resolve(directory, target);
713 if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
714 }
715 const configText = yield* fileSystem
716 .readFileString(path.join(gitDir, "config"))
717 .pipe(Effect.orElseSucceed(() => ""));
718 const originUrl = parseOriginUrlFromGitConfig(configText);
719 return {
720 _tag: "Repository",
721 git: {
722 remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
723 repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
724 },
725 } as const;
726 });
727
667 // A large history snapshot can precede session metadata. Read bounded728 // A large history snapshot can precede session metadata. Read bounded
668 // chunks until a complete record names its cwd or the safety budget ends.729 // chunks until a complete record names its cwd or the safety budget ends.
669 const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (730 const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
...@@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () {...@@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () {
1148 sources: Array<AgentSessionSource>;1209 sources: Array<AgentSessionSource>;
1149 threadCount: number;1210 threadCount: number;
1150 lastActiveAtMs: number | null;1211 lastActiveAtMs: number | null;
1212 git: AgentSessionProjectCandidate["git"];
1151 }1213 }
1152 >();1214 >();
1153 const directoryKeys = new Map<string, string>();1215 const directoryKeys = new Map<string, string>();
1216 const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();
11541217
1155 for (const candidate of raw) {1218 for (const candidate of raw) {
1156 const expanded = expandHomePath(candidate.cwd.trim());1219 const expanded = expandHomePath(candidate.cwd.trim());
...@@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () {...@@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () {
1173 if (isExcludedProjectPath(realPath)) {1236 if (isExcludedProjectPath(realPath)) {
1174 key = "";1237 key = "";
1175 } else {1238 } else {
1176 key = yield* directoryIdentity(resolved, stats.value);1239 const gitIdentity = yield* readGitIdentity(resolved);
1240 if (gitIdentity._tag === "Worktree") {
1241 key = "";
1242 } else {
1243 key = yield* directoryIdentity(resolved, stats.value);
1244 gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null);
1245 }
1177 }1246 }
1178 directoryKeys.set(resolved, key);1247 directoryKeys.set(resolved, key);
1179 }1248 }
...@@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () {...@@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () {
1186 sources: [candidate.source],1255 sources: [candidate.source],
1187 threadCount: candidate.threadCount,1256 threadCount: candidate.threadCount,
1188 lastActiveAtMs: candidate.lastActiveAtMs,1257 lastActiveAtMs: candidate.lastActiveAtMs,
1258 git: gitIdentities.get(key) ?? null,
1189 });1259 });
1190 continue;1260 continue;
1191 }1261 }
...@@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () {...@@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () {
1234 ? null1304 ? null
1235 : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),1305 : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
1236 alreadyImported: importedProject !== undefined,1306 alreadyImported: importedProject !== undefined,
1307 git: entry.git,
1237 });1308 });
1238 }1309 }
12391310
apps/web/src/components/onboarding/WelcomeWizard.tsx+264-41
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import type {3import type {
4 AgentSessionProjectCandidate,
4 EnvironmentId,5 EnvironmentId,
5 ProjectId,6 ProjectId,
6 ScopedProjectRef,7 ScopedProjectRef,
...@@ -32,10 +33,12 @@ import { hasCloudPublicConfig } from "../../cloud/publicConfig";...@@ -32,10 +33,12 @@ import { hasCloudPublicConfig } from "../../cloud/publicConfig";
32import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";33import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
33import { useCompleteOnboarding } from "../../onboarding/firstRun";34import { useCompleteOnboarding } from "../../onboarding/firstRun";
34import {35import {
36 groupOnboardingProjects,
35 partitionOnboardingProjects,37 partitionOnboardingProjects,
36 onboardingProjectKey,38 onboardingProjectKey,
37 resolveOnboardingLandingProject,39 resolveOnboardingLandingProject,
38 resolveOnboardingProjectId,40 resolveOnboardingProjectId,
41 type OnboardingProjectGroup,
39} from "../../onboarding/projectImport.logic";42} from "../../onboarding/projectImport.logic";
40import {43import {
41 getOnboardingProviderState,44 getOnboardingProviderState,
...@@ -59,6 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus";...@@ -59,6 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus";
59import { getDriverOption } from "../settings/providerDriverMeta";62import { getDriverOption } from "../settings/providerDriverMeta";
60import { TerminalViewport } from "../ThreadTerminalDrawer";63import { TerminalViewport } from "../ThreadTerminalDrawer";
61import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";64import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
65import { ClaudeAI, OpenAI } from "../Icons";
62import { T3Wordmark } from "../T3Wordmark";66import { T3Wordmark } from "../T3Wordmark";
63import { Button } from "../ui/button";67import { Button } from "../ui/button";
64import { Checkbox } from "../ui/checkbox";68import { Checkbox } from "../ui/checkbox";
...@@ -71,6 +75,7 @@ import { WizardPanel, WizardSteps } from "../ui/wizard";...@@ -71,6 +75,7 @@ import { WizardPanel, WizardSteps } from "../ui/wizard";
71import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";75import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
72import { toastManager } from "../ui/toast";76import { toastManager } from "../ui/toast";
73import { cn } from "../../lib/utils";77import { cn } from "../../lib/utils";
78import { formatRelativeTime } from "../../timestampFormat";
7479
75/**80/**
76 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a81 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
...@@ -1008,11 +1013,11 @@ function ImportStep({...@@ -1008,11 +1013,11 @@ function ImportStep({
1008 ),1013 ),
1009 [scans],1014 [scans],
1010 );1015 );
1011 const selected = candidates.filter((candidate) =>1016 const selectedKeys = useMemo(
1012 selectedPaths1017 () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)),
1013 ? selectedPaths.has(candidate.key)1018 [selectedPaths, recent],
1014 : recent.some((item) => item.key === candidate.key),
1015 );1019 );
1020 const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key));
10161021
1017 const finishAfterImport = () => {1022 const finishAfterImport = () => {
1018 const projectRef = resolveOnboardingLandingProject(1023 const projectRef = resolveOnboardingLandingProject(
...@@ -1176,13 +1181,38 @@ function ImportStep({...@@ -1176,13 +1181,38 @@ function ImportStep({
1176 title="Choose your projects"1181 title="Choose your projects"
1177 description="Import projects and conversations from your selected computers."1182 description="Import projects and conversations from your selected computers."
1178 >1183 >
1184 {candidates.length > 0 ? (
1185 <div className="mt-5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
1186 <span role="status">
1187 {selected.length} of {candidates.length} selected
1188 </span>
1189 <div className="flex items-center gap-1">
1190 <Button
1191 variant="ghost"
1192 size="xs"
1193 disabled={isImporting || selected.length === candidates.length}
1194 onClick={() => setSelectedPaths(new Set(candidates.map((item) => item.key)))}
1195 >
1196 Select all
1197 </Button>
1198 <Button
1199 variant="ghost"
1200 size="xs"
1201 disabled={isImporting || selected.length === 0}
1202 onClick={() => setSelectedPaths(new Set())}
1203 >
1204 Select none
1205 </Button>
1206 </div>
1207 </div>
1208 ) : null}
1179 <ScrollArea1209 <ScrollArea
1180 scrollFade1210 scrollFade
1181 className="mt-5 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"1211 className="mt-2 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
1182 >1212 >
1183 <div className="space-y-5 pr-3">1213 <div className="space-y-5 pr-3">
1184 {scans.map((scan) => {1214 {scans.map((scan) => {
1185 const groupCandidates = candidates.filter(1215 const scanCandidates = candidates.filter(
1186 (candidate) => candidate.environmentId === scan.environmentId,1216 (candidate) => candidate.environmentId === scan.environmentId,
1187 );1217 );
1188 const label =1218 const label =
...@@ -1191,10 +1221,12 @@ function ImportStep({...@@ -1191,10 +1221,12 @@ function ImportStep({
1191 return (1221 return (
1192 <fieldset1222 <fieldset
1193 key={scan.environmentId}1223 key={scan.environmentId}
1194 className="min-w-0 space-y-1.5"1224 className="min-w-0 space-y-0.5"
1195 disabled={isImporting}1225 disabled={isImporting}
1196 >1226 >
1197 <legend className="mb-2 text-sm font-medium">{label}</legend>1227 {scans.length > 1 ? (
1228 <legend className="mb-2 text-sm font-medium">{label}</legend>
1229 ) : null}
1198 {scan.isPending && scan.data === null ? (1230 {scan.isPending && scan.data === null ? (
1199 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">1231 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
1200 <Spinner className="size-4" />1232 <Spinner className="size-4" />
...@@ -1210,7 +1242,7 @@ function ImportStep({...@@ -1210,7 +1242,7 @@ function ImportStep({
1210 Retry1242 Retry
1211 </Button>1243 </Button>
1212 </div>1244 </div>
1213 ) : groupCandidates.length === 0 ? (1245 ) : scanCandidates.length === 0 ? (
1214 <p className="py-2 text-sm text-muted-foreground">1246 <p className="py-2 text-sm text-muted-foreground">
1215 No existing Claude Code or Codex projects found.1247 No existing Claude Code or Codex projects found.
1216 </p>1248 </p>
...@@ -1220,38 +1252,11 @@ function ImportStep({...@@ -1220,38 +1252,11 @@ function ImportStep({
1220 {SCAN_LIMIT_MESSAGE}1252 {SCAN_LIMIT_MESSAGE}
1221 </p>1253 </p>
1222 ) : null}1254 ) : null}
1223 {groupCandidates.map((candidate) => (1255 <ImportCandidateList
1224 <label1256 candidates={scanCandidates}
1225 key={candidate.key}1257 selectedKeys={selectedKeys}
1226 className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-border bg-background px-2.5 py-2 has-disabled:cursor-default"1258 onSelectionChange={setSelectedPaths}
1227 >1259 />
1228 <Checkbox
1229 checked={selected.some((item) => item.key === candidate.key)}
1230 onCheckedChange={(checked) => {
1231 const next = new Set(selected.map((item) => item.key));
1232 if (checked) next.add(candidate.key);
1233 else next.delete(candidate.key);
1234 setSelectedPaths(next);
1235 }}
1236 />
1237 <Tooltip>
1238 <TooltipTrigger
1239 render={<span className="min-w-0 flex-1 truncate font-mono text-xs" />}
1240 >
1241 {candidate.path}
1242 </TooltipTrigger>
1243 <TooltipPopup className="max-w-96 break-all font-mono">
1244 {candidate.path}
1245 </TooltipPopup>
1246 </Tooltip>
1247 <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
1248 {candidate.sources
1249 .map((source) => (source === "claudeAgent" ? "Claude" : "Codex"))
1250 .join(", ")}{" "}
1251 · {candidate.threadCount} {candidate.threadCount === 1 ? "thread" : "threads"}
1252 </span>
1253 </label>
1254 ))}
1255 </fieldset>1260 </fieldset>
1256 );1261 );
1257 })}1262 })}
...@@ -1280,6 +1285,224 @@ function ImportStep({...@@ -1280,6 +1285,224 @@ function ImportStep({
1280 );1285 );
1281}1286}
12821287
1288type ImportCandidate = AgentSessionProjectCandidate & {
1289 readonly environmentId: EnvironmentId;
1290 readonly key: string;
1291};
1292
1293/**
1294 * Repositories first, newest activity on top. Clones of one repository share
1295 * a group with a tri-state checkbox. Folders that are not git repositories
1296 * sit collapsed at the bottom so they stay reachable without adding noise.
1297 * Source icons appear only on repository rows so the columns stay still.
1298 */
1299function ImportCandidateList({
1300 candidates,
1301 selectedKeys,
1302 onSelectionChange,
1303}: {
1304 readonly candidates: ReadonlyArray<ImportCandidate>;
1305 readonly selectedKeys: ReadonlySet<string>;
1306 readonly onSelectionChange: (next: ReadonlySet<string>) => void;
1307}) {
1308 const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]);
1309 const setKeys = (keys: ReadonlyArray<string>, checked: boolean) => {
1310 const next = new Set(selectedKeys);
1311 for (const key of keys) {
1312 if (checked) next.add(key);
1313 else next.delete(key);
1314 }
1315 onSelectionChange(next);
1316 };
1317 const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length;
1318
1319 return (
1320 <>
1321 {repositories.map((group) => (
1322 <ImportRepositoryGroup
1323 key={group.key}
1324 group={group}
1325 selectedKeys={selectedKeys}
1326 onToggle={setKeys}
1327 />
1328 ))}
1329 {other.length > 0 ? (
1330 <Collapsible>
1331 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1332 <Checkbox
1333 checked={otherSelected === other.length}
1334 indeterminate={otherSelected > 0 && otherSelected < other.length}
1335 onCheckedChange={(checked) =>
1336 setKeys(
1337 other.map((candidate) => candidate.key),
1338 checked === true,
1339 )
1340 }
1341 />
1342 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1343 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1344 <span className="truncate text-sm text-muted-foreground">Other folders</span>
1345 <span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
1346 {other.length} {other.length === 1 ? "folder" : "folders"}
1347 </span>
1348 </CollapsibleTrigger>
1349 </div>
1350 <CollapsiblePanel>
1351 {other.map((candidate) => (
1352 <ImportCandidateRow
1353 key={candidate.key}
1354 candidate={candidate}
1355 label={candidate.path}
1356 nested
1357 checked={selectedKeys.has(candidate.key)}
1358 onCheckedChange={(checked) => setKeys([candidate.key], checked)}
1359 />
1360 ))}
1361 </CollapsiblePanel>
1362 </Collapsible>
1363 ) : null}
1364 </>
1365 );
1366}
1367
1368function ImportRepositoryGroup({
1369 group,
1370 selectedKeys,
1371 onToggle,
1372}: {
1373 readonly group: OnboardingProjectGroup<ImportCandidate>;
1374 readonly selectedKeys: ReadonlySet<string>;
1375 readonly onToggle: (keys: ReadonlyArray<string>, checked: boolean) => void;
1376}) {
1377 const keys = group.candidates.map((candidate) => candidate.key);
1378 const selectedCount = keys.filter((key) => selectedKeys.has(key)).length;
1379 const single = group.candidates.length === 1;
1380 const only = group.candidates[0];
1381 if (single && only !== undefined) {
1382 return (
1383 <ImportCandidateRow
1384 candidate={only}
1385 label={group.label}
1386 {...(group.repository === null ? {} : { secondary: only.path })}
1387 checked={selectedKeys.has(only.key)}
1388 onCheckedChange={(checked) => onToggle([only.key], checked)}
1389 />
1390 );
1391 }
1392 return (
1393 <Collapsible defaultOpen>
1394 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1395 <Checkbox
1396 checked={selectedCount === keys.length}
1397 indeterminate={selectedCount > 0 && selectedCount < keys.length}
1398 onCheckedChange={(checked) => onToggle(keys, checked === true)}
1399 />
1400 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1401 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1402 <span className="truncate text-sm font-medium">{group.label}</span>
1403 <ImportRowMeta
1404 sources={[...new Set(group.candidates.flatMap((c) => c.sources))]}
1405 threadCount={group.threadCount}
1406 lastActiveAt={group.lastActiveAt}
1407 />
1408 </CollapsibleTrigger>
1409 </div>
1410 <CollapsiblePanel>
1411 {group.candidates.map((candidate) => (
1412 <ImportCandidateRow
1413 key={candidate.key}
1414 candidate={candidate}
1415 label={candidate.path}
1416 nested
1417 checked={selectedKeys.has(candidate.key)}
1418 onCheckedChange={(checked) => onToggle([candidate.key], checked)}
1419 />
1420 ))}
1421 </CollapsiblePanel>
1422 </Collapsible>
1423 );
1424}
1425
1426function ImportCandidateRow({
1427 candidate,
1428 label,
1429 secondary,
1430 nested = false,
1431 checked,
1432 onCheckedChange,
1433}: {
1434 readonly candidate: ImportCandidate;
1435 readonly label: string;
1436 readonly secondary?: string;
1437 readonly nested?: boolean;
1438 readonly checked: boolean;
1439 readonly onCheckedChange: (checked: boolean) => void;
1440}) {
1441 return (
1442 <label
1443 className={cn(
1444 "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40 has-disabled:cursor-default",
1445 nested && "pl-8",
1446 )}
1447 >
1448 <Checkbox checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} />
1449 <Tooltip>
1450 <TooltipTrigger
1451 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}
1452 >
1453 <span className={cn("truncate", nested ? "font-mono text-xs" : "text-sm font-medium")}>
1454 {label}
1455 </span>
1456 {secondary !== undefined ? (
1457 <span className="truncate font-mono text-[11px] text-muted-foreground">
1458 {secondary}
1459 </span>
1460 ) : null}
1461 </TooltipTrigger>
1462 <TooltipPopup className="max-w-96 break-all font-mono">{candidate.path}</TooltipPopup>
1463 </Tooltip>
1464 <ImportRowMeta
1465 sources={nested ? null : candidate.sources}
1466 threadCount={candidate.threadCount}
1467 lastActiveAt={candidate.lastActiveAt}
1468 />
1469 </label>
1470 );
1471}
1472
1473/**
1474 * Trailing columns shared by every import row: source icons, thread count,
1475 * last activity. Each column has a fixed width and each icon has its own slot
1476 * so nothing shifts between rows that differ in sources or digit count.
1477 */
1478function ImportRowMeta({
1479 sources,
1480 threadCount,
1481 lastActiveAt,
1482}: {
1483 readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null;
1484 readonly threadCount: number;
1485 readonly lastActiveAt: string | null;
1486}) {
1487 const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt);
1488 // "just now" does not fit the fixed column, so collapse it.
1489 const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value;
1490 return (
1491 <span className="ml-auto grid shrink-0 grid-cols-[1rem_1rem_2.5rem_2.25rem] items-center gap-x-1 text-xs text-muted-foreground tabular-nums">
1492 <span className="flex size-4 items-center justify-center">
1493 {sources?.includes("claudeAgent") ? (
1494 <ClaudeAI className="size-3" aria-label="Claude Code" />
1495 ) : null}
1496 </span>
1497 <span className="flex size-4 items-center justify-center">
1498 {sources?.includes("codex") ? <OpenAI className="size-3" aria-label="Codex" /> : null}
1499 </span>
1500 <span className="text-right">{threadCount}</span>
1501 <span className="text-right whitespace-nowrap">{age}</span>
1502 </span>
1503 );
1504}
1505
1283// ── Shared bits ──────────────────────────────────────────────1506// ── Shared bits ──────────────────────────────────────────────
12841507
1285function StepShell({1508function StepShell({
apps/web/src/onboarding/projectImport.logic.test.ts+69-1
...@@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3...@@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 groupOnboardingProjects,
5 partitionOnboardingProjects,6 partitionOnboardingProjects,
6 onboardingProjectKey,7 onboardingProjectKey,
7 resolveOnboardingLandingProject,8 resolveOnboardingLandingProject,
...@@ -18,13 +19,19 @@ function candidate(...@@ -18,13 +19,19 @@ function candidate(
18 title: path.split("/").at(-1) ?? path,19 title: path.split("/").at(-1) ?? path,
19 path,20 path,
20 sources: ["codex"],21 sources: ["codex"],
21 threadCount: 1,22 threadCount: 3,
22 lastActiveAt: "2026-08-20T12:00:00.000Z",23 lastActiveAt: "2026-08-20T12:00:00.000Z",
23 alreadyImported: false,24 alreadyImported: false,
25 git: { remoteKey: null, repository: null },
24 ...overrides,26 ...overrides,
25 };27 };
26}28}
2729
30const github = (repository: string) => ({
31 remoteKey: `github.com/${repository.toLowerCase()}`,
32 repository,
33});
34
28describe("partitionOnboardingProjects", () => {35describe("partitionOnboardingProjects", () => {
29 it("keeps existing projects available for thread history import", () => {36 it("keeps existing projects available for thread history import", () => {
30 const imported = candidate("/projects/current", { alreadyImported: true });37 const imported = candidate("/projects/current", { alreadyImported: true });
...@@ -59,6 +66,67 @@ describe("partitionOnboardingProjects", () => {...@@ -59,6 +66,67 @@ describe("partitionOnboardingProjects", () => {
59 recent: [recent],66 recent: [recent],
60 });67 });
61 });68 });
69
70 it("keeps non-git folders and thin histories out of the default selection", () => {
71 const repo = candidate("/projects/repo");
72 const folder = candidate("/projects/folder", { git: null });
73 const thin = candidate("/projects/thin", { threadCount: 2 });
74
75 expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]);
76 });
77});
78
79describe("groupOnboardingProjects", () => {
80 it("groups clones by origin, keeps local repos separate, and folds non-git folders away", () => {
81 const main = candidate("/code/t3code", {
82 git: github("pingdotgg/t3code"),
83 threadCount: 79,
84 lastActiveAt: "2026-08-21T12:00:00.000Z",
85 });
86 const clone = candidate("/code/clones/t3code-2", {
87 git: github("pingdotgg/t3code"),
88 threadCount: 13,
89 lastActiveAt: "2026-08-10T12:00:00.000Z",
90 });
91 const older = candidate("/code/fleet", {
92 git: github("t3dotgg/fleet"),
93 threadCount: 295,
94 lastActiveAt: "2026-08-22T00:00:00.000Z",
95 });
96 const local = candidate("/code/scratch-repo", { title: "scratch-repo" });
97 const folder = candidate("/tmp/notes", { git: null });
98
99 const grouped = groupOnboardingProjects([main, clone, older, local, folder]);
100
101 expect(grouped.other).toEqual([folder]);
102 expect(
103 grouped.repositories.map((group) => ({
104 label: group.label,
105 paths: group.candidates.map((item) => item.path),
106 threadCount: group.threadCount,
107 lastActiveAt: group.lastActiveAt,
108 })),
109 ).toEqual([
110 {
111 label: "t3dotgg/fleet",
112 paths: ["/code/fleet"],
113 threadCount: 295,
114 lastActiveAt: "2026-08-22T00:00:00.000Z",
115 },
116 {
117 label: "pingdotgg/t3code",
118 paths: ["/code/t3code", "/code/clones/t3code-2"],
119 threadCount: 92,
120 lastActiveAt: "2026-08-21T12:00:00.000Z",
121 },
122 {
123 label: "scratch-repo",
124 paths: ["/code/scratch-repo"],
125 threadCount: 3,
126 lastActiveAt: "2026-08-20T12:00:00.000Z",
127 },
128 ]);
129 });
62});130});
63131
64describe("resolveOnboardingProjectId", () => {132describe("resolveOnboardingProjectId", () => {
apps/web/src/onboarding/projectImport.logic.ts+81-1
...@@ -2,8 +2,14 @@ import { findProjectByPath } from "@t3tools/client-runtime/state/projects";...@@ -2,8 +2,14 @@ import { findProjectByPath } from "@t3tools/client-runtime/state/projects";
2import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";2import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
33
4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
5/** One or two threads in a directory is usually a one-off question, not a project. */
6const DEFAULT_SELECTION_MIN_THREADS = 3;
57
6/** Existing projects still need their agent history imported, so every scan candidate is offered. */8/**
9 * Existing projects still need their agent history imported, so every scan
10 * candidate is offered. The default selection is narrower: git repositories
11 * active in the last 30 days with enough threads to look like real work.
12 */
7export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(13export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
8 candidates: ReadonlyArray<T>,14 candidates: ReadonlyArray<T>,
9 now = Date.now(),15 now = Date.now(),
...@@ -13,6 +19,8 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida...@@ -13,6 +19,8 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida
13 return {19 return {
14 available: candidates,20 available: candidates,
15 recent: candidates.filter((candidate) => {21 recent: candidates.filter((candidate) => {
22 if (candidate.git === null) return false;
23 if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false;
16 if (candidate.lastActiveAt === null) return false;24 if (candidate.lastActiveAt === null) return false;
17 const lastActiveAt = Date.parse(candidate.lastActiveAt);25 const lastActiveAt = Date.parse(candidate.lastActiveAt);
18 return lastActiveAt >= cutoff && lastActiveAt <= now;26 return lastActiveAt >= cutoff && lastActiveAt <= now;
...@@ -20,6 +28,78 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida...@@ -20,6 +28,78 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida
20 };28 };
21}29}
2230
31export interface OnboardingProjectGroup<T> {
32 /** Stable identity for collapse state and React keys. */
33 readonly key: string;
34 /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */
35 readonly label: string;
36 readonly repository: string | null;
37 readonly candidates: ReadonlyArray<T>;
38 readonly threadCount: number;
39 readonly lastActiveAt: string | null;
40}
41
42function latestActivity(left: string | null, right: string | null): string | null {
43 if (left === null) return right;
44 if (right === null) return left;
45 return left > right ? left : right;
46}
47
48/**
49 * Group scan candidates for the onboarding picker. Clones of one repository
50 * share a group keyed by their normalized origin URL. Repositories without an
51 * origin get a group each. Directories that are not git repositories are
52 * returned separately so the UI can fold them away by default. Groups sort by
53 * most recent activity, newest first.
54 */
55export function groupOnboardingProjects<
56 T extends Pick<
57 AgentSessionProjectCandidate,
58 "path" | "title" | "git" | "threadCount" | "lastActiveAt"
59 >,
60>(candidates: ReadonlyArray<T>) {
61 const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>();
62 const other: Array<T> = [];
63
64 for (const candidate of candidates) {
65 if (candidate.git === null) {
66 other.push(candidate);
67 continue;
68 }
69 const key =
70 candidate.git.remoteKey === null
71 ? `path:${candidate.path}`
72 : `remote:${candidate.git.remoteKey}`;
73 const existing = groups.get(key);
74 if (existing === undefined) {
75 groups.set(key, {
76 key,
77 label: candidate.git.repository ?? candidate.title,
78 repository: candidate.git.repository,
79 candidates: [candidate],
80 threadCount: candidate.threadCount,
81 lastActiveAt: candidate.lastActiveAt,
82 });
83 continue;
84 }
85 existing.candidates.push(candidate);
86 groups.set(key, {
87 ...existing,
88 threadCount: existing.threadCount + candidate.threadCount,
89 lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt),
90 });
91 }
92
93 const repositories = [...groups.values()].sort((left, right) => {
94 if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label);
95 if (left.lastActiveAt === null) return 1;
96 if (right.lastActiveAt === null) return -1;
97 return right.lastActiveAt.localeCompare(left.lastActiveAt);
98 });
99
100 return { repositories, other };
101}
102
23/** Use the server's project match before the client snapshot, which can lag behind the scan. */103/** Use the server's project match before the client snapshot, which can lag behind the scan. */
24export function resolveOnboardingProjectId(104export function resolveOnboardingProjectId(
25 projects: ReadonlyArray<{105 projects: ReadonlyArray<{
docs/user/welcome-wizard.md+10-3
...@@ -46,9 +46,16 @@ terminal metadata while the terminal process can use them....@@ -46,9 +46,16 @@ terminal metadata while the terminal process can use them.
4646
47## Import your projects47## Import your projects
4848
49T3 Code finds directories that Claude Code or Codex has used. The default49T3 Code finds directories that Claude Code or Codex has used. Git repositories
50selection includes projects active within the last 30 days. Use the checkboxes50are listed first, newest activity on top. When the remote is on GitHub, the
51to include older projects or change the selection.51group shows the repository as `owner/name`. Clones with the same remote share
52one group. Directories that are not git repositories sit under "Other folders".
53
54The default selection includes git repositories active within the last 30 days
55with at least three conversations. Use the checkboxes, or "Select all" and
56"Select none", to change the selection. Linked git worktrees, Codex scratch
57directories under `Documents/Codex`, and anything under `Downloads` are not
58offered.
5259
53A large or malformed history can reach the scan limit. T3 Code keeps the60A large or malformed history can reach the scan limit. T3 Code keeps the
54projects it found and warns when projects or conversations may be missing.61projects it found and warns when projects or conversations may be missing.
packages/contracts/src/agentSessions.ts+14
...@@ -37,6 +37,18 @@ export type AgentSessionScanInput = typeof AgentSessionScanInput.Type;...@@ -37,6 +37,18 @@ export type AgentSessionScanInput = typeof AgentSessionScanInput.Type;
37 * T3 Code project. `alreadyImported` marks candidates that already have an37 * T3 Code project. `alreadyImported` marks candidates that already have an
38 * active project rooted at the same path.38 * active project rooted at the same path.
39 */39 */
40/**
41 * Git identity of a candidate directory, read from `.git/config` without
42 * spawning git. `remoteKey` is the normalized origin URL, shared by every
43 * clone of the same repository so the client can group them. `repository`
44 * is the GitHub `owner/name` when the origin is on GitHub.
45 */
46export const AgentSessionProjectGit = Schema.Struct({
47 remoteKey: Schema.NullOr(Schema.String),
48 repository: Schema.NullOr(Schema.String),
49});
50export type AgentSessionProjectGit = typeof AgentSessionProjectGit.Type;
51
40export const AgentSessionProjectCandidate = Schema.Struct({52export const AgentSessionProjectCandidate = Schema.Struct({
41 path: TrimmedNonEmptyString,53 path: TrimmedNonEmptyString,
42 title: TrimmedNonEmptyString,54 title: TrimmedNonEmptyString,
...@@ -45,6 +57,8 @@ export const AgentSessionProjectCandidate = Schema.Struct({...@@ -45,6 +57,8 @@ export const AgentSessionProjectCandidate = Schema.Struct({
45 threadCount: NonNegativeInt,57 threadCount: NonNegativeInt,
46 lastActiveAt: Schema.NullOr(IsoDateTime),58 lastActiveAt: Schema.NullOr(IsoDateTime),
47 alreadyImported: Schema.Boolean,59 alreadyImported: Schema.Boolean,
60 /** `null` when the directory is not the root of a git repository. */
61 git: Schema.NullOr(AgentSessionProjectGit),
48});62});
49export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;63export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;
5064
packages/shared/src/git.test.ts+70
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 isTemporaryWorktreeBranch,7 isTemporaryWorktreeBranch,
8 normalizeGitRemoteUrl,8 normalizeGitRemoteUrl,
9 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,9 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
10 parseOriginUrlFromGitConfig,
10 WORKTREE_BRANCH_PREFIX,11 WORKTREE_BRANCH_PREFIX,
11} from "./git.ts";12} from "./git.ts";
1213
...@@ -51,6 +52,72 @@ describe("normalizeGitRemoteUrl", () => {...@@ -51,6 +52,72 @@ describe("normalizeGitRemoteUrl", () => {
51 });52 });
52});53});
5354
55describe("parseOriginUrlFromGitConfig", () => {
56 it("reads the origin url and ignores other remotes", () => {
57 const config = [
58 "[core]",
59 "\trepositoryformatversion = 0",
60 '[remote "upstream"]',
61 "\turl = https://github.com/other/repo.git",
62 '[remote "origin"]',
63 "\turl = git@github.com:pingdotgg/t3code.git",
64 "\tfetch = +refs/heads/*:refs/remotes/origin/*",
65 '[branch "main"]',
66 "\tremote = origin",
67 ].join("\n");
68 expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git");
69 });
70
71 it("strips inline comments and quotes from the url value", () => {
72 expect(
73 parseOriginUrlFromGitConfig(
74 '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n',
75 ),
76 ).toBe("https://github.com/acme/repo.git");
77 expect(
78 parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'),
79 ).toBe("git@github.com:acme/repo.git");
80 });
81
82 it("accepts legacy dotted headers, header comments, and line continuations", () => {
83 expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe(
84 "git@github.com:a/b.git",
85 );
86 // Git folds the dotted form to lowercase but keeps quoted names as written.
87 expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe(
88 "git@github.com:a/b.git",
89 );
90 expect(
91 parseOriginUrlFromGitConfig(
92 '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n',
93 ),
94 ).toBe("git@github.com:a/b.git");
95 expect(
96 parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'),
97 ).toBe("git@github.com:a/b.git");
98 expect(
99 parseOriginUrlFromGitConfig(
100 '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n',
101 ),
102 ).toBe("https://github.com/acme/repo.git");
103 });
104
105 it("falls back to the first remote when there is no origin", () => {
106 const config = [
107 '[remote "upstream"]',
108 "\turl = https://github.com/acme/repo.git",
109 '[remote "fork"]',
110 "\turl = https://github.com/me/repo.git",
111 ].join("\n");
112 expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git");
113 });
114
115 it("returns null when there is no remote section", () => {
116 expect(parseOriginUrlFromGitConfig("[core]\n\tbare = false\n")).toBeNull();
117 expect(parseOriginUrlFromGitConfig("")).toBeNull();
118 });
119});
120
54describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {121describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
55 it("extracts the owner and repository from common GitHub remote shapes", () => {122 it("extracts the owner and repository from common GitHub remote shapes", () => {
56 expect(123 expect(
...@@ -59,6 +126,9 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {...@@ -59,6 +126,9 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
59 expect(126 expect(
60 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"),127 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"),
61 ).toBe("T3Tools/T3Code");128 ).toBe("T3Tools/T3Code");
129 expect(
130 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://github.com/T3Tools/T3Code.git"),
131 ).toBe("T3Tools/T3Code");
62 });132 });
63});133});
64134
packages/shared/src/git.ts+65-1
...@@ -143,6 +143,70 @@ export function normalizeGitRemoteUrl(value: string): string {...@@ -143,6 +143,70 @@ export function normalizeGitRemoteUrl(value: string): string {
143 return normalized;143 return normalized;
144}144}
145145
146/**
147 * Unquote a git config value: strip an inline `#` or `;` comment outside
148 * quotes, then drop surrounding quotes and backslash escapes.
149 */
150function parseGitConfigValue(raw: string): string {
151 let out = "";
152 let quoted = false;
153 for (let index = 0; index < raw.length; index += 1) {
154 const char = raw[index]!;
155 if (char === "\\" && index + 1 < raw.length) {
156 out += raw[index + 1];
157 index += 1;
158 continue;
159 }
160 if (char === '"') {
161 quoted = !quoted;
162 continue;
163 }
164 if (!quoted && (char === "#" || char === ";")) break;
165 out += char;
166 }
167 return out.trim();
168}
169
170/**
171 * Read the primary remote URL from raw `.git/config` text without spawning
172 * git. Prefers `remote.origin.url` and falls back to the first remote so
173 * clones made with `git clone --origin <name>` still resolve.
174 */
175export function parseOriginUrlFromGitConfig(configText: string): string | null {
176 let section: string | null = null;
177 let originUrl: string | null = null;
178 let firstRemoteUrl: string | null = null;
179 // A trailing backslash continues the value on the next line.
180 const joined = configText.replace(/\\\r?\n[ \t]*/g, "");
181 for (const rawLine of joined.split(/\r?\n/)) {
182 const line = rawLine.trim();
183 if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue;
184 // Both `[remote "origin"]` and the legacy `[remote.origin]` form, with an
185 // optional trailing comment. Git keeps quoted subsections case-sensitive
186 // but folds the dotted form to lowercase.
187 const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](?:\s*[#;].*)?$/i.exec(line);
188 if (header) {
189 section = header[1] ?? header[2]?.toLowerCase() ?? null;
190 continue;
191 }
192 if (line.startsWith("[")) {
193 section = null;
194 continue;
195 }
196 if (section === null) continue;
197 const match = /^url\s*=\s*(.*)$/i.exec(line);
198 if (!match) continue;
199 const url = parseGitConfigValue(match[1] ?? "");
200 if (url.length === 0) continue;
201 if (section === "origin") {
202 originUrl ??= url;
203 } else {
204 firstRemoteUrl ??= url;
205 }
206 }
207 return originUrl ?? firstRemoteUrl;
208}
209
146/**210/**
147 * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.211 * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.
148 */212 */
...@@ -153,7 +217,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu...@@ -153,7 +217,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu
153 }217 }
154218
155 const match =219 const match =
156 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(220 /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
157 trimmed,221 trimmed,
158 );222 );
159 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";223 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";