| author | |
| committer | |
| log | 62fbbe08aa854fcbd8044cd971a45a54edafcb98 |
| tree | 53adb46b6032095ceff8cb7b4172135397952925 |
| parent | 1d1bf50405ccca8fdb8b57879d9def379e27a550 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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* () { |
| 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* () { |
| 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()); |
apps/web/src/onboarding/projectImport.logic.test.ts+17| ... | ... | @@ -74,6 +74,12 @@ describe("partitionOnboardingProjects", () => { |
| 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", () => { |
| 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", () => { |
apps/web/src/onboarding/projectImport.logic.ts+10-9| ... | ... | @@ -9,6 +9,8 @@ const DEFAULT_SELECTION_MIN_THREADS = 3; |
| 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 |
| 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< |
| 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, |
packages/contracts/src/agentSessions.test.ts created+36| ... | ... | @@ -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 | }); |
packages/contracts/src/agentSessions.ts+6-2| ... | ... | @@ -57,8 +57,12 @@ export const AgentSessionProjectCandidate = Schema.Struct({ |
| 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 |