Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/server/src/project/AgentSessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolveProviderInstanceEnabled,
type AgentSessionImportSource,
type AgentSessionProjectCandidate,
type AgentSessionProjectGit,
type AgentSessionScanResult,
type ProviderInstanceConfig,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -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" }
> {
Expand Down Expand Up @@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () {
sources: Array<AgentSessionSource>;
threadCount: number;
lastActiveAtMs: number | null;
git: AgentSessionProjectCandidate["git"];
git: AgentSessionProjectGit | null;
}
>();
const directoryKeys = new Map<string, string>();
const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();
const gitIdentities = new Map<string, AgentSessionProjectGit | null>();

for (const candidate of raw) {
const expanded = expandHomePath(candidate.cwd.trim());
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/onboarding/projectImport.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
19 changes: 10 additions & 9 deletions apps/web/src/onboarding/projectImport.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends AgentSessionProjectCandidate>(
candidates: ReadonlyArray<T>,
Expand Down Expand Up @@ -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<
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions packages/contracts/src/agentSessions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 6 additions & 2 deletions packages/contracts/src/agentSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading