| 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,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>(); |
| 1217 | 1218 | ||
| 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", () => { |
| 74 | 74 | ||
| 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 | }); |
| 78 | 84 | ||
| 79 | describe("groupOnboardingProjects", () => { | 85 | describe("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 | }); |
| 131 | 148 | ||
| 132 | describe("resolveOnboardingProjectId", () => { | 149 | describe("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 scan | 9 | * Existing projects still need their agent history imported, so every scan |
| 10 | * candidate is offered. The default selection is narrower: git repositories | 10 | * 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 | */ |
| 13 | export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>( | 15 | export 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 repository | 51 | * Group scan candidates for the onboarding picker. Clones of one repository |
| 50 | * share a group keyed by their normalized origin URL. Repositories without an | 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 | 53 | * 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 by | 54 | * 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 | */ |
| 55 | export function groupOnboardingProjects< | 58 | export 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 === null | 73 | 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 @@ | ||
| 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,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 | }); |
| 63 | export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; | 67 | export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; |
| 64 | 68 |