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() ?? "";