From 3492f0cf1fffb0e2e89c688eff390c75221d81e3 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 11:00:45 +0200 Subject: [PATCH] fix(projects): drop parent folders; attribute sessions to the deepest project 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. --- docs/PROGRESS_LOG.md | 6 +++++ src/http/projects.ts | 40 +++++++++++++++++++++++++++++++-- test/projects.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 1521028..6fb399c 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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 未处理。 diff --git a/src/http/projects.ts b/src/http/projects.ts index 497bd0c..3bc7e25 100644 --- a/src/http/projects.ts +++ b/src/http/projects.ts @@ -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 { 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 { 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 { + const byPath = new Map() + 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 { 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) } diff --git a/test/projects.test.ts b/test/projects.test.ts index 427736b..579fd2f 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -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