fix(projects): drop parent folders; attribute sessions to the deepest project
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

The projects panel listed bare parent folders (~, ~/Documents) as projects and
a single session lit up every ancestor project's 'Active now'. dropParentFolders
filters non-git entries that merely contain other listed projects; assignSessions
attributes each live session to the DEEPEST containing project (detail page keeps
prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
This commit is contained in:
Yaojia Wang
2026-07-06 11:00:45 +02:00
parent d36deb6922
commit 3492f0cf1f
3 changed files with 96 additions and 2 deletions

View File

@@ -24,6 +24,12 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~``~/Documents`)原样列为项目。
- **修复(TDD,先红后绿)**: ① 新增 `assignSessions` —— 每个 live 会话只归属**最深**的包含项目(`buildProjects` 改用;`buildProjectDetail` 保留前缀匹配,单项目详情页语义不变);② 新增 `dropParentFolders` —— 丢弃"非 git 且包含其他已列项目"的父文件夹条目(discovery 阶段过滤,进缓存)。
- **验证**: 新增 3 测(最深归属/父文件夹剔除/独立非 git 历史项目保留);`test/projects.test.ts` 29/29 绿;全量 `npm test` 53 files / **1473** 全绿;`tsc --noEmit` 干净。前端零改动("Active now" 分组消费 `sessions[]`,服务器修正后自然收敛)。
### ✅ iOS 客户端全面 UX/UI 打磨(完成 — 2026-07-05,分支 `feat/ios-client`,commit 660a404;精致原生方向)
用户定方向「精致原生 + 可重构」(靛紫 #7C8CFF 强调、语义状态色、SF Mono 数字、系统材质)。编排 = 一个 ultracode `Workflow`(9 agents,4 阶段):3 路审计(易用性/视觉/无障碍,各 boot 两 sim 取证)→ **冻结 DesignSystem**(协调点,防各 agent 各挑配色)→ 4 组文件互斥并行套用 → 验收。
- **审计**: 3 视角共产出 ~40 findings,共识根因=「无设计系统」——全 App stock 系统蓝、状态仅靠颜色(违反色+形,idle 蓝撞 unread 蓝)、gate 决策按钮 <44pt间距/圆角魔数无刻度数字非 tabular缩略图纯黑块gate 中英混排reduceMotion 未处理

View File

@@ -259,6 +259,18 @@ function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] {
return out
}
/**
* Drop bare parent folders: a non-git entry (from session history — e.g. the
* home dir or ~/Documents, where a session once ran) that merely contains
* other discovered projects is not a project itself; listing it would also
* double-claim its children's sessions in the UI.
*/
function dropParentFolders(projects: readonly ProjectInfo[]): ProjectInfo[] {
return projects.filter(
(p) => p.isGit || !projects.some((q) => q.path !== p.path && belongsTo(q.path, p.path)),
)
}
async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
@@ -267,7 +279,7 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
return makeProject({ path: repoPath, isGit: true, branch, dirty })
})
const merged = await mergeHistory(repos)
return dedupByPath(merged)
return dropParentFolders(dedupByPath(merged))
}
/**
@@ -333,6 +345,29 @@ function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): P
return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef)
}
/**
* Attribute each live session to the DEEPEST project containing its cwd.
* Plain prefix matching would also light up every ancestor project (e.g. a
* nested repo's session showing as active on the containing repo too).
*/
function assignSessions(
projects: readonly ProjectInfo[],
live: readonly LiveSessionInfo[],
): Map<string, ProjectSessionRef[]> {
const byPath = new Map<string, ProjectSessionRef[]>()
for (const s of live) {
let deepest: string | undefined
for (const p of projects) {
if (!belongsTo(s.cwd, p.path)) continue
if (deepest === undefined || p.path.length > deepest.length) deepest = p.path
}
if (deepest === undefined) continue
const refs = byPath.get(deepest) ?? []
byPath.set(deepest, [...refs, toSessionRef(s)])
}
return byPath
}
function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] {
return [...projects].sort((a, b) => {
if (a.lastActiveMs !== b.lastActiveMs) {
@@ -354,9 +389,10 @@ export async function buildProjects(
liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectInfo[]> {
const base = await discoverProjects(cfg)
const sessionsByPath = assignSessions(base, liveSessions)
const withSessions = base.map((p) => ({
...p,
sessions: matchSessions(p.path, liveSessions),
sessions: sessionsByPath.get(p.path) ?? [],
}))
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
}

View File

@@ -212,6 +212,28 @@ describe('buildProjects — session merge', () => {
expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
})
it('attributes a session only to the DEEPEST project containing its cwd', async () => {
// repoA is discovered by the scan; the nested repoA/sub repo enters via
// history (scan does not descend into repos). A session inside sub must
// NOT also light up the ancestor repoA.
const repoPath = path.join(tmp, 'repoA')
const subPath = path.join(repoPath, 'sub')
await makeRepo(repoPath, 'main')
await makeRepo(subPath, 'dev')
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: subPath, project: 'sub', mtimeMs: 1000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const live = [makeLive({ id: 's-deep', cwd: path.join(subPath, 'src') })]
const out = await buildProjects(cfg, live)
const repoA = out.find((p) => p.path === repoPath)!
const sub = out.find((p) => p.path === subPath)!
expect(sub.sessions.map((s) => s.id)).toEqual(['s-deep'])
expect(repoA.sessions).toEqual([])
})
it('re-merges live sessions FRESH on every call (discovery is cached)', async () => {
await makeRepo(path.join(tmp, 'repoA'), 'main')
const repoPath = path.join(tmp, 'repoA')
@@ -266,6 +288,36 @@ describe('buildProjects — history merge & sorting', () => {
}
})
it('drops non-git parent folders that merely contain other projects', async () => {
// Running a session in ~/Documents once puts it in history; it must not be
// listed as a project when it only contains real repos (parent folder).
const repoPath = path.join(tmp, 'repoA')
await makeRepo(repoPath, 'main')
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: tmp, project: path.basename(tmp), mtimeMs: 2000, preview: '' },
{ id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 1000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const out = await buildProjects(cfg, [])
expect(out.some((p) => p.path === tmp)).toBe(false) // parent folder dropped
expect(out.some((p) => p.path === repoPath)).toBe(true)
})
it('keeps a non-git history project that contains no other project', async () => {
const standalone = path.join(tmp, 'notes')
await fs.mkdir(standalone, { recursive: true })
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: standalone, project: 'notes', mtimeMs: 2000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const out = await buildProjects(cfg, [])
expect(out.some((p) => p.path === standalone)).toBe(true)
})
it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => {
await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined
await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined