authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 12:16:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 12:16:13-07:00
log62fbbe08aa854fcbd8044cd971a45a54edafcb98
tree53adb46b6032095ceff8cb7b4172135397952925
parent1d1bf50405ccca8fdb8b57879d9def379e27a550
signature Signed by PGP key B5690EEEBB952194

fix(web): tolerate servers that predate git identity in project import (#10547)

Co-authored-by: Claude Code <noreply@anthropic.com>

5 files changed, 73 insertions(+), 14 deletions(-)

apps/server/src/project/AgentSessionScanner.ts+4-3
......@@ -24,6 +24,7 @@ import {
2424 resolveProviderInstanceEnabled,
2525 type AgentSessionImportSource,
2626 type AgentSessionProjectCandidate,
27 type AgentSessionProjectGit,
2728 type AgentSessionScanResult,
2829 type ProviderInstanceConfig,
2930} from "@t3tools/contracts";
......@@ -695,7 +696,7 @@ export const make = Effect.gen(function* () {
695696 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
696697 directory: string,
697698 ): Effect.fn.Return<
698 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] }
699 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null }
699700 | { readonly _tag: "Worktree" }
700701 | { readonly _tag: "NotGit" }
701702 > {
......@@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () {
12091210 sources: Array<AgentSessionSource>;
12101211 threadCount: number;
12111212 lastActiveAtMs: number | null;
1212 git: AgentSessionProjectCandidate["git"];
1213 git: AgentSessionProjectGit | null;
12131214 }
12141215 >();
12151216 const directoryKeys = new Map<string, string>();
1216 const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();
1217 const gitIdentities = new Map<string, AgentSessionProjectGit | null>();
12171218
12181219 for (const candidate of raw) {
12191220 const expanded = expandHomePath(candidate.cwd.trim());
apps/web/src/onboarding/projectImport.logic.test.ts+17
......@@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => {
7474
7575 expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]);
7676 });
77
78 it("selects candidates from servers that do not report git identity", () => {
79 const { git: _git, ...legacy } = candidate("/projects/legacy");
80
81 expect(partitionOnboardingProjects([legacy], now).recent).toEqual([legacy]);
82 });
7783});
7884
7985describe("groupOnboardingProjects", () => {
......@@ -127,6 +133,17 @@ describe("groupOnboardingProjects", () => {
127133 },
128134 ]);
129135 });
136
137 it("lists candidates without git identity as standalone repositories", () => {
138 const { git: _git, ...legacy } = candidate("/code/legacy", { title: "legacy" });
139
140 const grouped = groupOnboardingProjects([legacy]);
141
142 expect(grouped.other).toEqual([]);
143 expect(grouped.repositories.map((group) => [group.label, group.repository])).toEqual([
144 ["legacy", null],
145 ]);
146 });
130147});
131148
132149describe("resolveOnboardingProjectId", () => {
apps/web/src/onboarding/projectImport.logic.ts+10-9
......@@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3;
99 * Existing projects still need their agent history imported, so every scan
1010 * candidate is offered. The default selection is narrower: git repositories
1111 * active in the last 30 days with enough threads to look like real work.
12 * Servers that predate the git scan omit `git`; their candidates are treated
13 * as repositories so old computers still get a useful default selection.
1214 */
1315export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
1416 candidates: ReadonlyArray<T>,
......@@ -48,9 +50,10 @@ function latestActivity(left: string | null, right: string | null): string | nul
4850/**
4951 * Group scan candidates for the onboarding picker. Clones of one repository
5052 * 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.
53 * origin get a group each, as do candidates from servers that do not report
54 * git identity. Directories that are not git repositories are returned
55 * separately so the UI can fold them away by default. Groups sort by most
56 * recent activity, newest first.
5457 */
5558export function groupOnboardingProjects<
5659 T extends Pick<
......@@ -66,16 +69,14 @@ export function groupOnboardingProjects<
6669 other.push(candidate);
6770 continue;
6871 }
69 const key =
70 candidate.git.remoteKey === null
71 ? `path:${candidate.path}`
72 : `remote:${candidate.git.remoteKey}`;
72 const git = candidate.git ?? { remoteKey: null, repository: null };
73 const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`;
7374 const existing = groups.get(key);
7475 if (existing === undefined) {
7576 groups.set(key, {
7677 key,
77 label: candidate.git.repository ?? candidate.title,
78 repository: candidate.git.repository,
78 label: git.repository ?? candidate.title,
79 repository: git.repository,
7980 candidates: [candidate],
8081 threadCount: candidate.threadCount,
8182 lastActiveAt: candidate.lastActiveAt,
packages/contracts/src/agentSessions.test.ts created+36
......@@ -0,0 +1,36 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { AgentSessionScanResult } from "./agentSessions.ts";
5
6const decodeScanResult = Schema.decodeUnknownSync(AgentSessionScanResult);
7
8const candidate = {
9 path: "/projects/repo",
10 title: "repo",
11 sources: ["codex"],
12 threadCount: 3,
13 lastActiveAt: "2026-08-20T12:00:00.000Z",
14 alreadyImported: false,
15} as const;
16
17describe("AgentSessionScanResult", () => {
18 it("decodes candidates from servers that predate the git scan", () => {
19 const result = decodeScanResult({
20 candidates: [candidate],
21 scannedAt: "2026-08-22T12:00:00.000Z",
22 });
23
24 expect(result.candidates[0]?.git).toBeUndefined();
25 });
26
27 it("preserves reported git identity", () => {
28 const git = { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" };
29 const result = decodeScanResult({
30 candidates: [{ ...candidate, git }],
31 scannedAt: "2026-08-22T12:00:00.000Z",
32 });
33
34 expect(result.candidates[0]?.git).toEqual(git);
35 });
36});
packages/contracts/src/agentSessions.ts+6-2
......@@ -57,8 +57,12 @@ export const AgentSessionProjectCandidate = Schema.Struct({
5757 threadCount: NonNegativeInt,
5858 lastActiveAt: Schema.NullOr(IsoDateTime),
5959 alreadyImported: Schema.Boolean,
60 /** `null` when the directory is not the root of a git repository. */
61 git: Schema.NullOr(AgentSessionProjectGit),
60 /**
61 * `null` when the directory is not the root of a git repository. Missing on
62 * servers that predate the git scan, where the client cannot tell repositories
63 * from plain folders and should treat every candidate as a standalone project.
64 */
65 git: Schema.optionalKey(Schema.NullOr(AgentSessionProjectGit)),
6266});
6367export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;
6468