Unified · Split
| @@ -24,6 +24,7 @@ import { · 23 unmodified lines | ||
| 24 | 24 | resolveProviderInstanceEnabled, |
| 25 | 25 | type AgentSessionImportSource, |
| 26 | 26 | type AgentSessionProjectCandidate, |
| 27 | + type AgentSessionProjectGit, | |
| 27 | 28 | type AgentSessionScanResult, |
| 28 | 29 | type ProviderInstanceConfig, |
| 29 | 30 | } from "@t3tools/contracts"; |
| @@ -695,7 +696,7 @@ export const make = Effect.gen(function* () { · 665 unmodified lines | ||
| 695 | 696 | const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( |
| 696 | 697 | directory: string, |
| 697 | 698 | ): Effect.fn.Return< |
| 698 | − | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] } | |
| 699 | + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } | |
| 699 | 700 | | { readonly _tag: "Worktree" } |
| 700 | 701 | | { readonly _tag: "NotGit" } |
| 701 | 702 | > { |
| @@ -1209,11 +1210,11 @@ export const make = Effect.gen(function* () { · 507 unmodified lines | ||
| 1209 | 1210 | sources: Array<AgentSessionSource>; |
| 1210 | 1211 | threadCount: number; |
| 1211 | 1212 | lastActiveAtMs: number | null; |
| 1212 | − git: AgentSessionProjectCandidate["git"]; | |
| 1213 | + git: AgentSessionProjectGit | null; | |
| 1213 | 1214 | } |
| 1214 | 1215 | >(); |
| 1215 | 1216 | const directoryKeys = new Map<string, string>(); |
| 1216 | − const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>(); | |
| 1217 | + const gitIdentities = new Map<string, AgentSessionProjectGit | null>(); | |
| 1217 | 1218 | |
| 1218 | 1219 | for (const candidate of raw) { |
| 1219 | 1220 | const expanded = expandHomePath(candidate.cwd.trim()); |
| @@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => { · 73 unmodified lines | ||
| 74 | 74 | |
| 75 | 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 | }); |
| 78 | 84 | |
| 79 | 85 | describe("groupOnboardingProjects", () => { |
| @@ -127,6 +133,17 @@ describe("groupOnboardingProjects", () => { · 47 unmodified lines | ||
| 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 | }); |
| 131 | 148 | |
| 132 | 149 | describe("resolveOnboardingProjectId", () => { |
| @@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3; · 8 unmodified lines | ||
| 9 | 9 | * Existing projects still need their agent history imported, so every scan |
| 10 | 10 | * candidate is offered. The default selection is narrower: git repositories |
| 11 | 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 | */ |
| 13 | 15 | export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>( |
| 14 | 16 | candidates: ReadonlyArray<T>, |
| @@ -48,9 +50,10 @@ function latestActivity(left: string | null, right: string | null): string | nul · 33 unmodified lines | ||
| 48 | 50 | /** |
| 49 | 51 | * Group scan candidates for the onboarding picker. Clones of one repository |
| 50 | 52 | * 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. | |
| 54 | 57 | */ |
| 55 | 58 | export function groupOnboardingProjects< |
| 56 | 59 | T extends Pick< |
| @@ -66,16 +69,14 @@ export function groupOnboardingProjects< · 9 unmodified lines | ||
| 66 | 69 | other.push(candidate); |
| 67 | 70 | continue; |
| 68 | 71 | } |
| 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}`; | |
| 73 | 74 | const existing = groups.get(key); |
| 74 | 75 | if (existing === undefined) { |
| 75 | 76 | groups.set(key, { |
| 76 | 77 | key, |
| 77 | − label: candidate.git.repository ?? candidate.title, | |
| 78 | − repository: candidate.git.repository, | |
| 78 | + label: git.repository ?? candidate.title, | |
| 79 | + repository: git.repository, | |
| 79 | 80 | candidates: [candidate], |
| 80 | 81 | threadCount: candidate.threadCount, |
| 81 | 82 | lastActiveAt: candidate.lastActiveAt, |
| @@ -0,0 +1,36 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import { AgentSessionScanResult } from "./agentSessions.ts"; | |
| 5 | + | |
| 6 | +const decodeScanResult = Schema.decodeUnknownSync(AgentSessionScanResult); | |
| 7 | + | |
| 8 | +const 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 | + | |
| 17 | +describe("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 | +}); | |
| @@ -57,8 +57,12 @@ export const AgentSessionProjectCandidate = Schema.Struct({ · 56 unmodified lines | ||
| 57 | 57 | threadCount: NonNegativeInt, |
| 58 | 58 | lastActiveAt: Schema.NullOr(IsoDateTime), |
| 59 | 59 | 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)), | |
| 62 | 66 | }); |
| 63 | 67 | export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; |
| 64 | 68 | |