From 8b2838e0e8a73d3fa6476940445c372e47b99db4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 7 Sep 2026 03:48:14 -0700 Subject: [PATCH] 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. --- .../src/project/AgentSessionScanner.test.ts | 118 +++++++ .../server/src/project/AgentSessionScanner.ts | 75 ++++- .../components/onboarding/WelcomeWizard.tsx | 305 +++++++++++++++--- .../onboarding/projectImport.logic.test.ts | 70 +++- .../web/src/onboarding/projectImport.logic.ts | 82 ++++- docs/user/welcome-wizard.md | 13 +- packages/contracts/src/agentSessions.ts | 14 + packages/shared/src/git.test.ts | 70 ++++ packages/shared/src/git.ts | 66 +++- 9 files changed, 764 insertions(+), 49 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 0ca5501605079ad36f4da800e0ccfdc9a0bfd2cd..c797eab6f550bd08a4c4960ffd9944d0494d3273 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-03-01T00:00:00.000Z", alreadyImported: false, + git: null, }, { path: olderWorkspace, @@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-02-09T11:00:00.000Z", alreadyImported: false, + git: null, }, { path: workspace, @@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-02-09T10:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), @@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-04-01T09:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspace, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { path: workspaceAlias, projectId: ProjectId.make("project-1"), alreadyImported: true, + git: null, }); }), ); @@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: true, + git: null, }, ]); }), @@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("excludes Codex scratch directories and Downloads", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + // The exclusions key off the real home directory, so these fixtures + // must live there. Each run owns a uniquely named subtree and removes + // only that subtree, never the shared Codex or Downloads parents. + const home = NodeOS.homedir(); + // Borrow a unique suffix from a scoped temp dir instead of reaching for + // Date.now or Math.random, which the Effect lint rejects. + const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); + const scratchRoot = path.join(home, "Documents", "Codex", runId); + const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); + const downloads = path.join(home, "Downloads", runId); + const keep = yield* makeTempDir("t3code-workspace-keep-"); + yield* fileSystem.makeDirectory(scratch, { recursive: true }); + yield* fileSystem.makeDirectory(downloads, { recursive: true }); + yield* Effect.addFinalizer(() => + Effect.all([ + fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), + fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), + ]), + ); + + for (const [index, cwd] of [scratch, downloads, keep].entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "09", + "01", + `rollout-${index}.jsonl`, + ), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); + }), + ); + + it.effect("skips linked git worktrees and reports the origin of real checkouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const worktree = yield* makeTempDir("t3code-workspace-worktree-"); + const plain = yield* makeTempDir("t3code-workspace-plain-"); + const noRemote = yield* makeTempDir("t3code-workspace-noremote-"); + const submodule = yield* makeTempDir("t3code-workspace-submodule-"); + + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString( + path.join(repo, ".git", "config"), + '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`, + ); + yield* fileSystem.makeDirectory(path.join(noRemote, ".git")); + yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n"); + // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`. + const submoduleGitDir = path.join(repo, ".git", "modules", "vendor"); + yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(submoduleGitDir, "config"), + '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + ); + yield* fileSystem.writeFileString( + path.join(submodule, ".git"), + `gitdir: ${submoduleGitDir}\n`, + ); + + for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { + path: submodule, + git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + }, + { path: noRemote, git: { remoteKey: null, repository: null } }, + { path: plain, git: null }, + { + path: repo, + git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }, + }, + ]); + }), + ); + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { threadCount: 1, lastActiveAt: "2026-05-03T00:00:00.000Z", alreadyImported: false, + git: null, }, ]); }), diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 06d57441b54c37a474e6786e32829f9eff94377b..83dc215cca409273aaf354c8f1a6fea81c70679b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -38,6 +38,11 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, +} from "@t3tools/shared/git"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; @@ -624,14 +629,28 @@ export const make = Effect.gen(function* () { // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; const hostEnvironment = yield* HostProcessEnvironment; + const homeDir = NodeOS.homedir(); + // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( - [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => normalizeProjectPathForComparison(path.resolve(directory)), ), ); + // Codex creates one scratch directory per conversation under + // ~/Documents/Codex//. Neither those nor anything a user + // unpacked into Downloads is a project. + const excludedProjectAncestors = [ + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents", "Codex"), + ]; const isExcludedProjectPath = (candidatePath: string) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + excludedProjectAncestors.some((ancestor) => + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(ancestor, foldWorktreeCase), + ), + ) || normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || @@ -664,6 +683,48 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); + /** + * Git identity of a directory, or the reason it has none. Reads `.git` + * directly instead of spawning git so a scan over hundreds of candidates + * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a + * `worktrees/` directory the checkout is a linked worktree, which + * onboarding skips because its history belongs to the main checkout. + * Submodules use the same pointer shape but live under `modules/`, and + * are offered like any other repository. + */ + const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( + directory: string, + ): Effect.fn.Return< + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" } + > { + const gitPath = path.join(directory, ".git"); + const gitStats = yield* statOption(gitPath); + if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; + let gitDir = gitPath; + if (gitStats.value.type !== "Directory") { + const pointer = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.orElseSucceed(() => "")); + const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; + gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + } + const configText = yield* fileSystem + .readFileString(path.join(gitDir, "config")) + .pipe(Effect.orElseSucceed(() => "")); + const originUrl = parseOriginUrlFromGitConfig(configText); + return { + _tag: "Repository", + git: { + remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), + repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + }, + } as const; + }); + // A large history snapshot can precede session metadata. Read bounded // chunks until a complete record names its cwd or the safety budget ends. const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () { sources: Array; threadCount: number; lastActiveAtMs: number | null; + git: AgentSessionProjectCandidate["git"]; } >(); const directoryKeys = new Map(); + const gitIdentities = new Map(); for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim()); @@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () { if (isExcludedProjectPath(realPath)) { key = ""; } else { - key = yield* directoryIdentity(resolved, stats.value); + const gitIdentity = yield* readGitIdentity(resolved); + if (gitIdentity._tag === "Worktree") { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); + } } directoryKeys.set(resolved, key); } @@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () { sources: [candidate.source], threadCount: candidate.threadCount, lastActiveAtMs: candidate.lastActiveAtMs, + git: gitIdentities.get(key) ?? null, }); continue; } @@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () { ? null : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), alreadyImported: importedProject !== undefined, + git: entry.git, }); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 24e38d353a873b3ae209ae621a6012ea029a5479..ca187b3475d1333e4d33995c387308299b8a6997 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1,6 +1,7 @@ import { useAuth } from "@clerk/react"; import { useAtomValue } from "@effect/atom-react"; import type { + AgentSessionProjectCandidate, EnvironmentId, ProjectId, ScopedProjectRef, @@ -32,10 +33,12 @@ import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { + groupOnboardingProjects, partitionOnboardingProjects, onboardingProjectKey, resolveOnboardingLandingProject, resolveOnboardingProjectId, + type OnboardingProjectGroup, } from "../../onboarding/projectImport.logic"; import { getOnboardingProviderState, @@ -59,6 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus"; import { getDriverOption } from "../settings/providerDriverMeta"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { ClaudeAI, OpenAI } from "../Icons"; import { T3Wordmark } from "../T3Wordmark"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -71,6 +75,7 @@ import { WizardPanel, WizardSteps } from "../ui/wizard"; import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; +import { formatRelativeTime } from "../../timestampFormat"; /** * First-run welcome wizard. Rendered over the workspace at `/welcome` on a @@ -1008,11 +1013,11 @@ function ImportStep({ ), [scans], ); - const selected = candidates.filter((candidate) => - selectedPaths - ? selectedPaths.has(candidate.key) - : recent.some((item) => item.key === candidate.key), + const selectedKeys = useMemo( + () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)), + [selectedPaths, recent], ); + const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key)); const finishAfterImport = () => { const projectRef = resolveOnboardingLandingProject( @@ -1176,13 +1181,38 @@ function ImportStep({ title="Choose your projects" description="Import projects and conversations from your selected computers." > + {candidates.length > 0 ? ( +
+ + {selected.length} of {candidates.length} selected + +
+ + +
+
+ ) : null}
{scans.map((scan) => { - const groupCandidates = candidates.filter( + const scanCandidates = candidates.filter( (candidate) => candidate.environmentId === scan.environmentId, ); const label = @@ -1191,10 +1221,12 @@ function ImportStep({ return (
- {label} + {scans.length > 1 ? ( + {label} + ) : null} {scan.isPending && scan.data === null ? (
@@ -1210,7 +1242,7 @@ function ImportStep({ Retry
- ) : groupCandidates.length === 0 ? ( + ) : scanCandidates.length === 0 ? (

No existing Claude Code or Codex projects found.

@@ -1220,38 +1252,11 @@ function ImportStep({ {SCAN_LIMIT_MESSAGE}

) : null} - {groupCandidates.map((candidate) => ( - - ))} +
); })} @@ -1280,6 +1285,224 @@ function ImportStep({ ); } +type ImportCandidate = AgentSessionProjectCandidate & { + readonly environmentId: EnvironmentId; + readonly key: string; +}; + +/** + * Repositories first, newest activity on top. Clones of one repository share + * a group with a tri-state checkbox. Folders that are not git repositories + * sit collapsed at the bottom so they stay reachable without adding noise. + * Source icons appear only on repository rows so the columns stay still. + */ +function ImportCandidateList({ + candidates, + selectedKeys, + onSelectionChange, +}: { + readonly candidates: ReadonlyArray; + readonly selectedKeys: ReadonlySet; + readonly onSelectionChange: (next: ReadonlySet) => void; +}) { + const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]); + const setKeys = (keys: ReadonlyArray, checked: boolean) => { + const next = new Set(selectedKeys); + for (const key of keys) { + if (checked) next.add(key); + else next.delete(key); + } + onSelectionChange(next); + }; + const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length; + + return ( + <> + {repositories.map((group) => ( + + ))} + {other.length > 0 ? ( + +
+ 0 && otherSelected < other.length} + onCheckedChange={(checked) => + setKeys( + other.map((candidate) => candidate.key), + checked === true, + ) + } + /> + + + Other folders + + {other.length} {other.length === 1 ? "folder" : "folders"} + + +
+ + {other.map((candidate) => ( + setKeys([candidate.key], checked)} + /> + ))} + +
+ ) : null} + + ); +} + +function ImportRepositoryGroup({ + group, + selectedKeys, + onToggle, +}: { + readonly group: OnboardingProjectGroup; + readonly selectedKeys: ReadonlySet; + readonly onToggle: (keys: ReadonlyArray, checked: boolean) => void; +}) { + const keys = group.candidates.map((candidate) => candidate.key); + const selectedCount = keys.filter((key) => selectedKeys.has(key)).length; + const single = group.candidates.length === 1; + const only = group.candidates[0]; + if (single && only !== undefined) { + return ( + onToggle([only.key], checked)} + /> + ); + } + return ( + +
+ 0 && selectedCount < keys.length} + onCheckedChange={(checked) => onToggle(keys, checked === true)} + /> + + + {group.label} + c.sources))]} + threadCount={group.threadCount} + lastActiveAt={group.lastActiveAt} + /> + +
+ + {group.candidates.map((candidate) => ( + onToggle([candidate.key], checked)} + /> + ))} + +
+ ); +} + +function ImportCandidateRow({ + candidate, + label, + secondary, + nested = false, + checked, + onCheckedChange, +}: { + readonly candidate: ImportCandidate; + readonly label: string; + readonly secondary?: string; + readonly nested?: boolean; + readonly checked: boolean; + readonly onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +/** + * Trailing columns shared by every import row: source icons, thread count, + * last activity. Each column has a fixed width and each icon has its own slot + * so nothing shifts between rows that differ in sources or digit count. + */ +function ImportRowMeta({ + sources, + threadCount, + lastActiveAt, +}: { + readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly threadCount: number; + readonly lastActiveAt: string | null; +}) { + const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt); + // "just now" does not fit the fixed column, so collapse it. + const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; + return ( + + + {sources?.includes("claudeAgent") ? ( + + ) : null} + + + {sources?.includes("codex") ? : null} + + {threadCount} + {age} + + ); +} + // ── Shared bits ────────────────────────────────────────────── function StepShell({ diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts index 58264759b82a14e8b24aa4e0eeb6d6dbca49a491..2ddfb2de6e644edc02e0672de25ad2b289897358 100644 --- a/apps/web/src/onboarding/projectImport.logic.test.ts +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3 import { describe, expect, it } from "vite-plus/test"; import { + groupOnboardingProjects, partitionOnboardingProjects, onboardingProjectKey, resolveOnboardingLandingProject, @@ -18,13 +19,19 @@ function candidate( title: path.split("/").at(-1) ?? path, path, sources: ["codex"], - threadCount: 1, + threadCount: 3, lastActiveAt: "2026-08-20T12:00:00.000Z", alreadyImported: false, + git: { remoteKey: null, repository: null }, ...overrides, }; } +const github = (repository: string) => ({ + remoteKey: `github.com/${repository.toLowerCase()}`, + repository, +}); + describe("partitionOnboardingProjects", () => { it("keeps existing projects available for thread history import", () => { const imported = candidate("/projects/current", { alreadyImported: true }); @@ -59,6 +66,67 @@ describe("partitionOnboardingProjects", () => { recent: [recent], }); }); + + it("keeps non-git folders and thin histories out of the default selection", () => { + const repo = candidate("/projects/repo"); + const folder = candidate("/projects/folder", { git: null }); + const thin = candidate("/projects/thin", { threadCount: 2 }); + + expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]); + }); +}); + +describe("groupOnboardingProjects", () => { + it("groups clones by origin, keeps local repos separate, and folds non-git folders away", () => { + const main = candidate("/code/t3code", { + git: github("pingdotgg/t3code"), + threadCount: 79, + lastActiveAt: "2026-08-21T12:00:00.000Z", + }); + const clone = candidate("/code/clones/t3code-2", { + git: github("pingdotgg/t3code"), + threadCount: 13, + lastActiveAt: "2026-08-10T12:00:00.000Z", + }); + const older = candidate("/code/fleet", { + git: github("t3dotgg/fleet"), + threadCount: 295, + lastActiveAt: "2026-08-22T00:00:00.000Z", + }); + const local = candidate("/code/scratch-repo", { title: "scratch-repo" }); + const folder = candidate("/tmp/notes", { git: null }); + + const grouped = groupOnboardingProjects([main, clone, older, local, folder]); + + expect(grouped.other).toEqual([folder]); + expect( + grouped.repositories.map((group) => ({ + label: group.label, + paths: group.candidates.map((item) => item.path), + threadCount: group.threadCount, + lastActiveAt: group.lastActiveAt, + })), + ).toEqual([ + { + label: "t3dotgg/fleet", + paths: ["/code/fleet"], + threadCount: 295, + lastActiveAt: "2026-08-22T00:00:00.000Z", + }, + { + label: "pingdotgg/t3code", + paths: ["/code/t3code", "/code/clones/t3code-2"], + threadCount: 92, + lastActiveAt: "2026-08-21T12:00:00.000Z", + }, + { + label: "scratch-repo", + paths: ["/code/scratch-repo"], + threadCount: 3, + lastActiveAt: "2026-08-20T12:00:00.000Z", + }, + ]); + }); }); describe("resolveOnboardingProjectId", () => { diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts index 962fe1e0abd80b3d01f79a19af2aad299c824a01..3fa78128748abe0a04f4773678a4156bcc927537 100644 --- a/apps/web/src/onboarding/projectImport.logic.ts +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -2,8 +2,14 @@ import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +/** One or two threads in a directory is usually a one-off question, not a project. */ +const DEFAULT_SELECTION_MIN_THREADS = 3; -/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +/** + * Existing projects still need their agent history imported, so every scan + * candidate is offered. The default selection is narrower: git repositories + * active in the last 30 days with enough threads to look like real work. + */ export function partitionOnboardingProjects( candidates: ReadonlyArray, now = Date.now(), @@ -13,6 +19,8 @@ export function partitionOnboardingProjects { + if (candidate.git === null) return false; + if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false; if (candidate.lastActiveAt === null) return false; const lastActiveAt = Date.parse(candidate.lastActiveAt); return lastActiveAt >= cutoff && lastActiveAt <= now; @@ -20,6 +28,78 @@ export function partitionOnboardingProjects { + /** Stable identity for collapse state and React keys. */ + readonly key: string; + /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */ + readonly label: string; + readonly repository: string | null; + readonly candidates: ReadonlyArray; + readonly threadCount: number; + readonly lastActiveAt: string | null; +} + +function latestActivity(left: string | null, right: string | null): string | null { + if (left === null) return right; + if (right === null) return left; + return left > right ? left : right; +} + +/** + * Group scan candidates for the onboarding picker. Clones of one repository + * share a group keyed by their normalized origin URL. Repositories without an + * origin get a group each. Directories that are not git repositories are + * returned separately so the UI can fold them away by default. Groups sort by + * most recent activity, newest first. + */ +export function groupOnboardingProjects< + T extends Pick< + AgentSessionProjectCandidate, + "path" | "title" | "git" | "threadCount" | "lastActiveAt" + >, +>(candidates: ReadonlyArray) { + const groups = new Map & { candidates: Array }>(); + const other: Array = []; + + for (const candidate of candidates) { + if (candidate.git === null) { + other.push(candidate); + continue; + } + const key = + candidate.git.remoteKey === null + ? `path:${candidate.path}` + : `remote:${candidate.git.remoteKey}`; + const existing = groups.get(key); + if (existing === undefined) { + groups.set(key, { + key, + label: candidate.git.repository ?? candidate.title, + repository: candidate.git.repository, + candidates: [candidate], + threadCount: candidate.threadCount, + lastActiveAt: candidate.lastActiveAt, + }); + continue; + } + existing.candidates.push(candidate); + groups.set(key, { + ...existing, + threadCount: existing.threadCount + candidate.threadCount, + lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt), + }); + } + + const repositories = [...groups.values()].sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { repositories, other }; +} + /** Use the server's project match before the client snapshot, which can lag behind the scan. */ export function resolveOnboardingProjectId( projects: ReadonlyArray<{ diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index b169a7b7f251c21992356419317a8f396d6a1180..d124ab6efbe2dfbe48cdbf0cc3392c81fc1897bd 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -46,9 +46,16 @@ terminal metadata while the terminal process can use them. ## Import your projects -T3 Code finds directories that Claude Code or Codex has used. The default -selection includes projects active within the last 30 days. Use the checkboxes -to include older projects or change the selection. +T3 Code finds directories that Claude Code or Codex has used. Git repositories +are listed first, newest activity on top. When the remote is on GitHub, the +group shows the repository as `owner/name`. Clones with the same remote share +one group. Directories that are not git repositories sit under "Other folders". + +The default selection includes git repositories active within the last 30 days +with at least three conversations. Use the checkboxes, or "Select all" and +"Select none", to change the selection. Linked git worktrees, Codex scratch +directories under `Documents/Codex`, and anything under `Downloads` are not +offered. A large or malformed history can reach the scan limit. T3 Code keeps the projects it found and warns when projects or conversations may be missing. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index ffd90dd79db84896ef767c4339ec394269f6feb0..0248f75860a75135b947bdb91cc3bb8cf179a77b 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -37,6 +37,18 @@ export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; * T3 Code project. `alreadyImported` marks candidates that already have an * active project rooted at the same path. */ +/** + * Git identity of a candidate directory, read from `.git/config` without + * spawning git. `remoteKey` is the normalized origin URL, shared by every + * clone of the same repository so the client can group them. `repository` + * is the GitHub `owner/name` when the origin is on GitHub. + */ +export const AgentSessionProjectGit = Schema.Struct({ + remoteKey: Schema.NullOr(Schema.String), + repository: Schema.NullOr(Schema.String), +}); +export type AgentSessionProjectGit = typeof AgentSessionProjectGit.Type; + export const AgentSessionProjectCandidate = Schema.Struct({ path: TrimmedNonEmptyString, title: TrimmedNonEmptyString, @@ -45,6 +57,8 @@ export const AgentSessionProjectCandidate = Schema.Struct({ threadCount: NonNegativeInt, lastActiveAt: Schema.NullOr(IsoDateTime), alreadyImported: Schema.Boolean, + /** `null` when the directory is not the root of a git repository. */ + git: Schema.NullOr(AgentSessionProjectGit), }); export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423c1854f11aea4e56274381673f435..9d7ed527088d2f9fe893518ef5fe185d77eccdc8 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,7 @@ import { isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; @@ -51,6 +52,72 @@ describe("normalizeGitRemoteUrl", () => { }); }); +describe("parseOriginUrlFromGitConfig", () => { + it("reads the origin url and ignores other remotes", () => { + const config = [ + "[core]", + "\trepositoryformatversion = 0", + '[remote "upstream"]', + "\turl = https://github.com/other/repo.git", + '[remote "origin"]', + "\turl = git@github.com:pingdotgg/t3code.git", + "\tfetch = +refs/heads/*:refs/remotes/origin/*", + '[branch "main"]', + "\tremote = origin", + ].join("\n"); + expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git"); + }); + + it("strips inline comments and quotes from the url value", () => { + expect( + parseOriginUrlFromGitConfig( + '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n', + ), + ).toBe("https://github.com/acme/repo.git"); + expect( + parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'), + ).toBe("git@github.com:acme/repo.git"); + }); + + it("accepts legacy dotted headers, header comments, and line continuations", () => { + expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe( + "git@github.com:a/b.git", + ); + // Git folds the dotted form to lowercase but keeps quoted names as written. + expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe( + "git@github.com:a/b.git", + ); + expect( + parseOriginUrlFromGitConfig( + '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n', + ), + ).toBe("git@github.com:a/b.git"); + expect( + parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'), + ).toBe("git@github.com:a/b.git"); + expect( + parseOriginUrlFromGitConfig( + '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n', + ), + ).toBe("https://github.com/acme/repo.git"); + }); + + it("falls back to the first remote when there is no origin", () => { + const config = [ + '[remote "upstream"]', + "\turl = https://github.com/acme/repo.git", + '[remote "fork"]', + "\turl = https://github.com/me/repo.git", + ].join("\n"); + expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git"); + }); + + it("returns null when there is no remote section", () => { + expect(parseOriginUrlFromGitConfig("[core]\n\tbare = false\n")).toBeNull(); + expect(parseOriginUrlFromGitConfig("")).toBeNull(); + }); +}); + describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { it("extracts the owner and repository from common GitHub remote shapes", () => { expect( @@ -59,6 +126,9 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { expect( parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583189f8e094e9c2c7942408b338ba6..8e618d2d64415dac1ba9189337ac234eb79e14ea 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -143,6 +143,70 @@ export function normalizeGitRemoteUrl(value: string): string { return normalized; } +/** + * Unquote a git config value: strip an inline `#` or `;` comment outside + * quotes, then drop surrounding quotes and backslash escapes. + */ +function parseGitConfigValue(raw: string): string { + let out = ""; + let quoted = false; + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index]!; + if (char === "\\" && index + 1 < raw.length) { + out += raw[index + 1]; + index += 1; + continue; + } + if (char === '"') { + quoted = !quoted; + continue; + } + if (!quoted && (char === "#" || char === ";")) break; + out += char; + } + return out.trim(); +} + +/** + * Read the primary remote URL from raw `.git/config` text without spawning + * git. Prefers `remote.origin.url` and falls back to the first remote so + * clones made with `git clone --origin ` still resolve. + */ +export function parseOriginUrlFromGitConfig(configText: string): string | null { + let section: string | null = null; + let originUrl: string | null = null; + let firstRemoteUrl: string | null = null; + // A trailing backslash continues the value on the next line. + const joined = configText.replace(/\\\r?\n[ \t]*/g, ""); + for (const rawLine of joined.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue; + // Both `[remote "origin"]` and the legacy `[remote.origin]` form, with an + // optional trailing comment. Git keeps quoted subsections case-sensitive + // but folds the dotted form to lowercase. + const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](?:\s*[#;].*)?$/i.exec(line); + if (header) { + section = header[1] ?? header[2]?.toLowerCase() ?? null; + continue; + } + if (line.startsWith("[")) { + section = null; + continue; + } + if (section === null) continue; + const match = /^url\s*=\s*(.*)$/i.exec(line); + if (!match) continue; + const url = parseGitConfigValue(match[1] ?? ""); + if (url.length === 0) continue; + if (section === "origin") { + originUrl ??= url; + } else { + firstRemoteUrl ??= url; + } + } + return originUrl ?? firstRemoteUrl; +} + /** * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. */ @@ -153,7 +217,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; -- 2.54.0