diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 83dc215cca40..1dfd1d5df8af 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -24,6 +24,7 @@ import { resolveProviderInstanceEnabled, type AgentSessionImportSource, type AgentSessionProjectCandidate, + type AgentSessionProjectGit, type AgentSessionScanResult, type ProviderInstanceConfig, } from "@t3tools/contracts"; @@ -695,7 +696,7 @@ export const make = Effect.gen(function* () { const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( directory: string, ): Effect.fn.Return< - | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] } + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } | { readonly _tag: "Worktree" } | { readonly _tag: "NotGit" } > { @@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () { sources: Array; threadCount: number; lastActiveAtMs: number | null; - git: AgentSessionProjectCandidate["git"]; + git: AgentSessionProjectGit | null; } >(); const directoryKeys = new Map(); - const gitIdentities = new Map(); + const gitIdentities = new Map(); for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim()); diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts index 2ddfb2de6e64..a675cae4a538 100644 --- a/apps/web/src/onboarding/projectImport.logic.test.ts +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => { expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]); }); + + it("selects candidates from servers that do not report git identity", () => { + const { git: _git, ...legacy } = candidate("/projects/legacy"); + + expect(partitionOnboardingProjects([legacy], now).recent).toEqual([legacy]); + }); }); describe("groupOnboardingProjects", () => { @@ -127,6 +133,17 @@ describe("groupOnboardingProjects", () => { }, ]); }); + + it("lists candidates without git identity as standalone repositories", () => { + const { git: _git, ...legacy } = candidate("/code/legacy", { title: "legacy" }); + + const grouped = groupOnboardingProjects([legacy]); + + expect(grouped.other).toEqual([]); + expect(grouped.repositories.map((group) => [group.label, group.repository])).toEqual([ + ["legacy", null], + ]); + }); }); describe("resolveOnboardingProjectId", () => { diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts index 3fa78128748a..d7044b98b6af 100644 --- a/apps/web/src/onboarding/projectImport.logic.ts +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3; * 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. + * Servers that predate the git scan omit `git`; their candidates are treated + * as repositories so old computers still get a useful default selection. */ export function partitionOnboardingProjects( candidates: ReadonlyArray, @@ -48,9 +50,10 @@ function latestActivity(left: string | null, right: string | null): string | nul /** * 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. + * origin get a group each, as do candidates from servers that do not report + * git identity. 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< @@ -66,16 +69,14 @@ export function groupOnboardingProjects< other.push(candidate); continue; } - const key = - candidate.git.remoteKey === null - ? `path:${candidate.path}` - : `remote:${candidate.git.remoteKey}`; + const git = candidate.git ?? { remoteKey: null, repository: null }; + const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`; const existing = groups.get(key); if (existing === undefined) { groups.set(key, { key, - label: candidate.git.repository ?? candidate.title, - repository: candidate.git.repository, + label: git.repository ?? candidate.title, + repository: git.repository, candidates: [candidate], threadCount: candidate.threadCount, lastActiveAt: candidate.lastActiveAt, diff --git a/packages/contracts/src/agentSessions.test.ts b/packages/contracts/src/agentSessions.test.ts new file mode 100644 index 000000000000..ed19646661a9 --- /dev/null +++ b/packages/contracts/src/agentSessions.test.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { AgentSessionScanResult } from "./agentSessions.ts"; + +const decodeScanResult = Schema.decodeUnknownSync(AgentSessionScanResult); + +const candidate = { + path: "/projects/repo", + title: "repo", + sources: ["codex"], + threadCount: 3, + lastActiveAt: "2026-08-20T12:00:00.000Z", + alreadyImported: false, +} as const; + +describe("AgentSessionScanResult", () => { + it("decodes candidates from servers that predate the git scan", () => { + const result = decodeScanResult({ + candidates: [candidate], + scannedAt: "2026-08-22T12:00:00.000Z", + }); + + expect(result.candidates[0]?.git).toBeUndefined(); + }); + + it("preserves reported git identity", () => { + const git = { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }; + const result = decodeScanResult({ + candidates: [{ ...candidate, git }], + scannedAt: "2026-08-22T12:00:00.000Z", + }); + + expect(result.candidates[0]?.git).toEqual(git); + }); +}); diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index 0248f75860a7..ab408c4efd17 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -57,8 +57,12 @@ 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), + /** + * `null` when the directory is not the root of a git repository. Missing on + * servers that predate the git scan, where the client cannot tell repositories + * from plain folders and should treat every candidate as a standalone project. + */ + git: Schema.optionalKey(Schema.NullOr(AgentSessionProjectGit)), }); export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;