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 {...@@ -24,6 +24,7 @@ import {
24 resolveProviderInstanceEnabled,24 resolveProviderInstanceEnabled,
25 type AgentSessionImportSource,25 type AgentSessionImportSource,
26 type AgentSessionProjectCandidate,26 type AgentSessionProjectCandidate,
27 type AgentSessionProjectGit,
27 type AgentSessionScanResult,28 type AgentSessionScanResult,
28 type ProviderInstanceConfig,29 type ProviderInstanceConfig,
29} from "@t3tools/contracts";30} from "@t3tools/contracts";
...@@ -695,7 +696,7 @@ export const make = Effect.gen(function* () {...@@ -695,7 +696,7 @@ export const make = Effect.gen(function* () {
695 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (696 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
696 directory: string,697 directory: string,
697 ): Effect.fn.Return<698 ): Effect.fn.Return<
698 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] }699 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null }
699 | { readonly _tag: "Worktree" }700 | { readonly _tag: "Worktree" }
700 | { readonly _tag: "NotGit" }701 | { readonly _tag: "NotGit" }
701 > {702 > {
...@@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () {...@@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () {
1209 sources: Array<AgentSessionSource>;1210 sources: Array<AgentSessionSource>;
1210 threadCount: number;1211 threadCount: number;
1211 lastActiveAtMs: number | null;1212 lastActiveAtMs: number | null;
1212 git: AgentSessionProjectCandidate["git"];1213 git: AgentSessionProjectGit | null;
1213 }1214 }
1214 >();1215 >();
1215 const directoryKeys = new Map<string, string>();1216 const directoryKeys = new Map<string, string>();
1216 const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();1217 const gitIdentities = new Map<string, AgentSessionProjectGit | null>();
12171218
1218 for (const candidate of raw) {1219 for (const candidate of raw) {
1219 const expanded = expandHomePath(candidate.cwd.trim());1220 const expanded = expandHomePath(candidate.cwd.trim());
apps/web/src/onboarding/projectImport.logic.test.ts+17
...@@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => {...@@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => {
7474
75 expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]);75 expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]);
76 });76 });
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 });
77});83});
7884
79describe("groupOnboardingProjects", () => {85describe("groupOnboardingProjects", () => {
...@@ -127,6 +133,17 @@ describe("groupOnboardingProjects", () => {...@@ -127,6 +133,17 @@ describe("groupOnboardingProjects", () => {
127 },133 },
128 ]);134 ]);
129 });135 });
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 });
130});147});
131148
132describe("resolveOnboardingProjectId", () => {149describe("resolveOnboardingProjectId", () => {
apps/web/src/onboarding/projectImport.logic.ts+10-9
...@@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3;...@@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3;
9 * Existing projects still need their agent history imported, so every scan9 * Existing projects still need their agent history imported, so every scan
10 * candidate is offered. The default selection is narrower: git repositories10 * candidate is offered. The default selection is narrower: git repositories
11 * active in the last 30 days with enough threads to look like real work.11 * 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.
12 */14 */
13export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(15export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
14 candidates: ReadonlyArray<T>,16 candidates: ReadonlyArray<T>,
...@@ -48,9 +50,10 @@ function latestActivity(left: string | null, right: string | null): string | nul...@@ -48,9 +50,10 @@ function latestActivity(left: string | null, right: string | null): string | nul
48/**50/**
49 * Group scan candidates for the onboarding picker. Clones of one repository51 * Group scan candidates for the onboarding picker. Clones of one repository
50 * share a group keyed by their normalized origin URL. Repositories without an52 * share a group keyed by their normalized origin URL. Repositories without an
51 * origin get a group each. Directories that are not git repositories are53 * origin get a group each, as do candidates from servers that do not report
52 * returned separately so the UI can fold them away by default. Groups sort by54 * git identity. Directories that are not git repositories are returned
53 * most recent activity, newest first.55 * separately so the UI can fold them away by default. Groups sort by most
56 * recent activity, newest first.
54 */57 */
55export function groupOnboardingProjects<58export function groupOnboardingProjects<
56 T extends Pick<59 T extends Pick<
...@@ -66,16 +69,14 @@ export function groupOnboardingProjects<...@@ -66,16 +69,14 @@ export function groupOnboardingProjects<
66 other.push(candidate);69 other.push(candidate);
67 continue;70 continue;
68 }71 }
69 const key =72 const git = candidate.git ?? { remoteKey: null, repository: null };
70 candidate.git.remoteKey === null73 const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`;
71 ? `path:${candidate.path}`
72 : `remote:${candidate.git.remoteKey}`;
73 const existing = groups.get(key);74 const existing = groups.get(key);
74 if (existing === undefined) {75 if (existing === undefined) {
75 groups.set(key, {76 groups.set(key, {
76 key,77 key,
77 label: candidate.git.repository ?? candidate.title,78 label: git.repository ?? candidate.title,
78 repository: candidate.git.repository,79 repository: git.repository,
79 candidates: [candidate],80 candidates: [candidate],
80 threadCount: candidate.threadCount,81 threadCount: candidate.threadCount,
81 lastActiveAt: candidate.lastActiveAt,82 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({...@@ -57,8 +57,12 @@ export const AgentSessionProjectCandidate = Schema.Struct({
57 threadCount: NonNegativeInt,57 threadCount: NonNegativeInt,
58 lastActiveAt: Schema.NullOr(IsoDateTime),58 lastActiveAt: Schema.NullOr(IsoDateTime),
59 alreadyImported: Schema.Boolean,59 alreadyImported: Schema.Boolean,
60 /** `null` when the directory is not the root of a git repository. */60 /**
61 git: Schema.NullOr(AgentSessionProjectGit),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)),
62});66});
63export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;67export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;
6468